0%

虚拟化技术概述

虚拟机特性:

  • 指令模拟 (guest 和 host 可以是不同的isa)
  • 本地指令直接执行( guest 和 host isa是相同的)

系统资源虚拟化:

  • cpu虚拟化
  • 内存虚拟化
  • io 虚拟化

常见的虚拟机软件:

  • vmware
  • virtual box
  • Parallels Desktop
  • Xen
  • linux-kvm qemu
  • Xvisor

image-20240416111454675

Hypervisor Virtual Machine Manager(VMM) 的功能

  • 控制所有的系统资源(CPU 内存 网络 存储等)
  • 创建虚拟机并分配响应的资源
  • 管理虚拟机的生命周期

VMM 调度程序和操作系统的调度进程类似, 操作系统调度的基本单位是进程/线程, VMM调度的单位是虚拟处理器. 当虚拟cpu被调度到时, VMM调度程序负责将vcpu上下文装载到物理处理器上, 然后vcpu所对应的guest os 指令开始真正被执行. 当时间片用完或虚拟处理器主动让出, 调度程序被触发. 调度程序根据调度策略, 挑选下一个vcpu继续运行.
与操作系统一样, VMM的调度策略可以有多种, 如平均时间片策略, 或按vcpu的权重分配时间片进行调度等.
虚拟机之间可以通信, VMM需要实现对应的通信机制, 并向虚拟机提供对应的api(可以是时间通知, 共享内存等), 需要严格的安全权限检查.

虚拟机环境管理包括创建/删除/暂停/查询/迁移等, 由虚拟机管理软件和VMM 管理接口组成.

物理资源的管理

  1. 处理器管理
    包括系统启动检测获取所有物理处理器, 对每个处理器进行初始化, 如设置运行模式, 设置页表, 设置中断处理函数等; 将所有的处理器纳入调度序列, 由调度程序对其进行调度. 还支持hot plug, 当有处理器插入时, vmm获得通知, 将其纳入调度序列. 当处理器拔出时, vmm 得到通知, 将该处理器上执行的任务迁移到其他处理器上, 并将其从管理队列中删除.

云服务存在多个处理器节点, 当某些节点出现故障时, 其上运行的guest os 不会宕机, 而是转移到其他正常工作的cpu节点上;
同样为了安全的扩充处理器资源, 在不断电的基础上进行cpu的扩充进而降低系统负载, 对服务器来说也是必要的;
2. 内存管理
系统启动时VMM检测并获取所有内存, 对获得的内存进行初始化, 包括分页设置页表等; 提供内存分配的接口, 给虚拟机分配内存, 并且维护虚拟机物理地址和实际物理地址的映射关系
3. 中断管理
根据中断来源, 或直接处理, 或转发给guest os 处理
4. 系统时间维护
VMM 拥有和时间相关的硬件资源, VMM 负责维护系统时间, 同时向各guest os 提供虚拟化的时间
5. 设备管理
所有的外设都属于VMM, VMM需要包含所有设备的驱动程序. 在混合模型下, 大部分的外部设备属于guest os, 少部分的设备属于VMM

虚拟化的优点:

  • 更高的系统安全性和可用性
    • VMM 作为监视层, 运行在比os更高的特权层
    • 控制过滤虚拟机的行为
    • 监控虚拟机状态, 故障快速恢复
  • 最大化硬件资源使用率
    • 在一个物理主机上创建多个虚拟机共享主机资源, 节约硬件成本
  • 系统易扩展
    • 修改虚拟机的配置来适应业务的负载变化
  • 方便的可移植性
    • 虚拟机的系统消除物理主机的硬件差异
    • 虚拟机以文件镜像的格式封装
  • 硬件级别的隔离特性
    • 通过iommu技术隔离外设

不同类型的VMM:

baremetal vmm (type1)

  • 启动时bootloader/BIOS 直接将执行权限交给hypervisor

  • 直接运行在硬件上, 不依赖基础操作系统

  • 可以控制所有的guest os

  • 交互少, 性能好, 稳定

  • 无法直接利用现有操作系统生态, 硬件兼容性差, 驱动开发工作量大

    VMM根据产品定位, 有选择的挑选一些io 设备来支持, 如面向服务器市场, 只会挑选服务器上的io设备来开发驱动, 另外调度和电源管理等的很多功能需要在VMM中重新实现

  • 典型代表: Xen
    image-20240416111459876

hosted vmm (type2)

  • 启动时bl/BIOS 先启动host os, hypervisor/VMM 相当于host os中跑的一个应用
  • 需要通过host os 来访问硬件资源
  • vmm 只能控制guest os, 不能控制host os中的其他部分
  • guest os 和 host os 交互调用链长, 影响性能
  • 攻击窗口多, 安全性差
  • 可以直接利用现有操作系统生态, 硬件兼容性好
  • 典型代表: linux kvm
    image-20240416111504708

KVM的思想是在Linux内核的基础上添加虚拟机管理模块,重用Linux内核中已经完善的进程调度、内存管理、IO管理等代码,使之成为一个可以支持运行虚拟机的Hypervisor

image-20240416111508288

混合模型

上述两种模式的集合体, VMM依然位于最底层, 拥有所有的物理资源, 与type1 模式不同的是, VMM会让出大部分io设备的控制权, 将它们交给guest os 控制, 相应的, VMM虚拟化的职责被分担, 处理器和内存的虚拟化仍由VMM 完成, 而IO虚拟化则由VMM和guest os 合作完成.

Hypervisor 的实现

半虚拟化:

  • 将guest os 降权, 使其无法直接访问系统特权资源
  • vmm 提供访问系统特权资源的hyper call api
  • 修改guest os, 用hyper call api 访问系统特权资源
  • 高效轻量, 性能好
  • guest os 修改量大, 使用不便

全虚拟化:

  • 将guest os 运行在vmm 创建的独立环境里
  • vmm 将内核特权访问操作翻译成一系列对vmm 的请求 (软件方案)
  • guest os对虚拟化环境不感知, 不需要修改guest os
  • vmm 实现复杂
  • vmm和guest os 之间翻译产生的负载比较大, 性能差

硬件虚拟化在半虚拟化和全虚拟化基础上提供了硬件辅助

用来简化VMM实现, 提高性能

  • intel VT-x VT-d
  • AMD svm iommu
  • ARM hypervisor层 smmu
  • riscv h-extension层 iommu

CPU 虚拟化

虚拟CPU上下文

  • 类似于进程上下文的概念,当虚拟机发生退出时,需要保存虚拟CPU中各寄 存器的状态
  • 发生虚拟CPU调度时,需要保存当前虚拟CPU的上下文并加载待调度虚拟 CPU上下文

image-20240416111513395

软件方案

x86 虚拟化技术早期, 没有cpu虚拟化的硬件支持, VMM 运行在特权级, guest os运行在非特权级(用户态), 这种方式称为特权级压缩(Ring Compression). guest os 上内核运行特权指令时, 通常会触发异常, 进入特权级, 由VMM 截获异常并进行处理, 但是有一些非特权敏感指令并不会触发异常, 这种状态下, VMM就需要扫描guest os的内核的所有的这些不会触发异常的敏感指令, 将其翻译成支持虚拟化的指令(会触发异常的指令), vmm 再去处理这些翻译后指令触发的异常.

修改系统资源的,或者在不同模式下行为有不同表现的,都属于敏感指令

硬件辅助方案

软件方案是非常低效的, 且vmm 实现过于复杂, Intel VT-x、AMD SVM、ARM EL2、RISC-V H-Extension, 加入了一层特权级别, guest os 运行在这一层上, 这一层上所有特权指令均会触发异常, 可以被VMM截获处理

image-20240416111517531

中断虚拟化

虚拟化系统中, 设置中断处理流程

1. 设备产生一个水平/边缘触发中断信号
2. 中断控制器响应该信号, 让cpu 进入VMM host 内核态的 中断异常模式
3. CPU 调用VMM 中断服务程序, 通过IRQ number 找到对应的guest os, 通过`中断注入程序`向guest os 注入virtual irq
4. guest os cpu进入guest 内核态的中断异常模式
5. guest os 调用中断服务程序, 通过virtual irq number 找到对应的驱动中断处理函数
6. 完成中断处理 

image-20240416111521451

内存虚拟化

常见的内存虚拟化技术

  1. 地址空间分区
    • 简单, 但不灵活
  2. 半虚拟化 shadow page table
    • 虚拟地址模拟物理地址, 性能好, 需要修改guest os

硬件虚拟化- 二级地址翻译

实际上是扩展了影子页表技术, 硬件辅助化的措施: 新增寄存器,供硬件MMU读取, 由硬件mmu 完成二级页表翻译, 避免由软件翻译, 运行时无需vmm 介入.

  1. IPA (intermediate physical address) guest 物理地址
  2. 虚拟机物理地址空间由IPA 描述, 不直接指向真实物理地址
  3. 每个VM的IPA地址独立, 可以相同, 可以重叠, 也可以完全不同
  4. 通过两级地址翻译找到真实物理地址
    1. VA->IPA (guest os)
      arm64 使用TTBRn_EL1寄存器和页表, riscv 使用stap寄存器
    2. IPA->PA (VMM)
      arm64 使用VTTBR_EL2寄存器和stage2 页表, riscv 使用hgatp 寄存器

IO 模拟

image-20240416111526004

  • type2 类 用户态设备模型:

    • QEMU/KVM

    • 用户态设备模型, 运行库生态健壮, 可复用性高

    • 多次上下文切换

  • type1 类 baremental 设备模型:

    • xvisor xen

    • 减少了多次上下文切换

    • 缩短io模拟路径

    • 移植性差

设备类型

  1. MMIO (Memory-mapped I/O)
    特定物理内存区域映射了设备的寄存器, os通过页表以访问内存的方式访问设备寄存器, RISCV 仅支持MMIO
  2. DMA
    无需CPU控制, DMA控制器接管地址总线

IO 虚拟化基本任务

  • 访问截获
  • 提供设备接口
    • 虚拟设备接口, 如暴露虚拟pci 设备
    • 直通设备接口, 如intel VT-d 技术
    • 对虚拟机完全“透明”
  • 实现设备功能
    • type1 类baremental VMM 需要实现设备驱动, 设备模型;
    • type2 类需要实现用户态设备模型 运行库等

MMIO模拟过程

虚拟机陷入过程

  • 访存指令,非特权指令
  • 页表不存在相应页表项 -> 缺页异常 -> 陷入VM

Hypervisor中的处理

  • 为MMIO区域注册一个MMIO处理函数
  • 处理函数定位到需要访问的I/O端口

DMA 模拟

  • guest 驱动程序配置DMA 相关寄存器, 源地址 目的地址 (GPA) 及 长度
  • 陷入VMM, 设备模型用VMM 提供的内存管理功能 将源地址目的地址 (GPA) 翻译为 HPA, 配置物理DMA 的地址寄存器为HPA, 同时需要建立HPA 与 HVA的映射关系, 对其进行占位, 防止别的进程或vcpu把这块物理内存给抢走. 这个地方纯软件实现比较复杂, 涉及到映射给 guest 的虚拟设备地址与真实设备地址之间的转换, 如果为memory->memory, 会简单一些
  • guest 客户机驱动程序通过配置虚拟DMA的 命令控制寄存器发起 DMA操作
  • 陷入VMM, 设备模型截获这个操作后, 配置物理DMA命令控制寄存器
  • DMA 自行在HPA 间搬运数据
  • DMA 搬运完毕后, 通过中断通知vmm 设备模型, vmm设备模型返回到guest os中, DMA 请求结束

硬件io虚拟化辅助-设备直通

软件实现I/O虚拟化的技术中,所有的虚拟机都共享物理平台上的硬件设备。如果物理条件好,有足够的硬件,就可以考虑让每个虚拟机独占一个物理设备,这样无疑会提高系统的性能。把某一个设备直接分配给一个虚拟机,让虚拟机可以直接访问该物理设备而不需要通过VMM或被VMM截获,这就是设备直通技术。

intel 的 VT-d与AMD的IOMMU技术 arm的smmu 技术。SMMU 与 IOMMU 提供了外设的中断重映射和DMA重映射功能, 使guest os使用该外设同host os 使用外设一样, 在使用过程中不需要陷入VMM.

中断重映射会将来自外部设备的中断拦截下来, 通过查询中断映射表找到真正的中断路由信息然后发送给真正的CPU。 guest 可以直接收到中断, 处理流程同物理主机处理中断的流程一样.

ARM 硬件虚拟化技术:

CPU 特权层扩展

  • EL2 层(arm64) / HYP (arm32) 模式下运行vmm
  • guest os 运行在原有的特权模式, 不需要修改guest os
  • vmm所在层权限更高, 可以控制guest os 访问硬件的权限

image-20240416111531638

模式切换

虚拟机 → Hypervisor

  • EL1 → EL2
  • 敏感指令触发(可通过HCR_EL2寄存器细粒度 控制)

Hypervisor → 虚拟机

  • EL2 → EL1
  • eret指令触发

上下文切换

  • EL1与EL2各自有一套系统寄存器
  • 虚拟CPU调度时,需要将原虚拟CPU系统寄 存器保存至内存并从内存中加载目标虚拟 CPU寄存器

因为有了硬件虚拟化的支持,所以hypervisor的实现 基本是基于硬件的 trap 和 软件的emulator 来实现的。guest os 访问一些特权寄存器或者指令,会进到 hypervisor ,然后会调用特权寄存器的访问函数来访问特权寄存器。如果是要访问硬盘,或者网络,会通过io 模拟器,来访问具体的模拟器。

image-20240416111535749

CPU的虚拟化,就是让多个Guest os 分时的运行在同一个CPU上,都有自己独立的物理地址空间,让 hypervisor在EL2 层来帮助多个VM 来进行上下文的切换,这个和linux 进程的概念非常的相似,不过保存的上下文寄存器不一样,这里有两个重要的寄存器,HCR_EL2ESR_EL2。HCR_EL2 是用来配置VM的参数,就是产生trap的条件,什么情况下会产生trap陷入到hypervisor ,右边是一个运行两个VM的例子。WFI指令是说明自己工作做完了,是idle状态了。

image-20240416111539904

ARM CPU虚拟化通过硬件trap和软件模拟完成

  1. HCR_EL2 hyper配置寄存器
    1. 配置vm产生硬件trap的条件, 如TLB/cache的操作, 一些特殊指令
  2. ESR_EL2 异常寄存器
    1. 当trap发生时, 确定vm产生硬件trap的原因

执行特权指令示例:

与特权级无关的一般的指令和通用寄存器在任何特权级都可以任意执行。而每个特权级都对应一些特殊指令和 控制状态寄存器 (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不只是具有有读写 CSR 的指令,还有其他功能的特权指令。

如果低优先级下的处理器执行了高优先级的指令,会产生非法指令错误的异常,于是位于高特权级的执行环境能够得知低优先级的软件出现了该错误, 进而陷入到高特权级处理该指令

image-20240416111543865

访问特定寄存器示例:

image-20240416111547192

使用陷入来虚拟化操作需要大量计算。比如功能寄存器ID_AA64MMFR0_EL1(用来报告处理器内存相关特性的,操作系统可能会读取该寄存器来决定在内核中开启或关闭某些特性),不经常被操作系统访问。当将对这些寄存器的访问捕获到虚拟机监控程序中以模拟读取时,计算是可以接受的。
但是对于访问频率高的寄存器,比如MPIDR_EL1,或者在性能关键代码中,需要尽可能地优化陷入,对这些寄存器,ARM提供了其它策略,hypervisor可以在进入VM时先配置好这些寄存器的值。例如,当VM中读到MPIDR_EL1(在多处理器系统中,为进程调度提供一个额外的PE(process element)识别机制)时会自动返回VMPIDR_EL2的值而不发生陷入

MMU虚拟化支持

  • LPAE(arm32 大地址拓展技术) , stage2 translation

img

中断虚拟化

Hypervisor对虚拟中断的处理比较复杂,Hypervisor本身需要机制来在EL2处理中断,还需要机制来将外设的中断信号发送到目标虚拟机VM(或vCPU)上,为了使能这些机制,ARM体系架构包含了对虚拟中断的支持(vIRQs,vFIQs,vSErrors);

处理器只有在EL0/EL1执行状态下,才能收到虚拟中断,在EL2/EL3状态下不能收到虚拟中断;

Hypervisor通过设置HCR_EL2寄存器来控制向EL0/EL1发送虚拟中断,比如为了使能vIRQ,需要设置HCR_EL2.IMO,设置后便会将物理中断发送至EL2,然后使能将虚拟中断发送至EL1;

有两种方式可以产生虚拟中断:

1)在处理器内部控制HCR_EL2寄存器产生虚拟中断, hypervisor还需要为VM模拟中断控制器的操作

2)通过GIC中断控制器(v2版本以上);

image-20221020105426023

如果一个PE上运行了多个guest os, 即一个PE上绑定了多个vcpu, 同时每个vcpu上跑的os是不同的.

发送虚拟中断则需要在安装中断时绑定vcpu 与 中断号.

虚拟CPU接口直接访问

  • 为虚拟CPU接口提供专用寄存器,区别于物理CPU接口
  • 运行在EL1中的Guest OS对CPU接口系统寄存器的访问可以被重定向到相应的寄存器而不会触发虚拟机陷入

arch_timer 虚拟化

  • hypervisor timer, virtual timer

无需陷入VMM, 防止引入不确定性的时延. 对于os 来说, timer tick 涉及到任务调度, 越精确越好.

smmu

  • stage2 translation for DMA

  • 一种DMA重映射机制

  • 扩大设备DMA寻址范围, 当系统无法提供大块连续物理内存时,也可以通过SMMU转换让设备可以访问分散物理内存

  • IOMMU在ARM-V8架构下的解决方案,与VT-d类似

  • SMMU与MMU共用一套阶段-2页 表

  • 设备直通

    • 虚拟机直接接管设备,虚机可以直接访问MMIO空间,VMM配置好SMMU之后,设备DMA读写请求也无需VMM介入
  • 为每个虚拟机划定可用的设备, 起到隔离保护作用

虚拟机网卡如何进行DMA?

image-20240416111555905

  • 网卡驱动将数据的guest 物理地址填入网卡DMA 寄存器
  • SMMU 硬件将发给DMA的guest 物理地址转换为真实设备物理地址

SMMU 为系统除CPU 之外的任何具有DMA 能力的设备提供地址翻译服务和保护功能

  • PCIE DMA 设备
  • platform DMA 设备
  • GPU/VE 加速器

image-20240416111559481

RISCV 虚拟化技术

已有的RISC-V虚拟化方案实现

目前已有的实现有Xvisor和KVM,Xvisor是1类虚拟化软件,而KVM属于2类。

RISC-V规范定义了RISC-V H-extension,在原来的3级特权架构的基础上,对原有的Supervisor模式进行了扩展,引入了Hypervisor-Extended Supervisor mode (HS)

image-20240416111603241

虚拟化H扩展定义了一个硬件状态位,称作V状态,可以为0或1,V状态不同,定义和访问的CSR寄存器也不同。

  • 当V为0时
    • 以“s”开头的CSR寄存器表示当前操作系统的状态
    • “h”开头的用于支持和实现虚拟化软件
    • “vs”开头的代表运行在虚拟化技术上的系统状态。
  • 当V为1时
    • “s”开头的寄存器指向了前文以“vs”开头的寄存器。

image-20240416111606294

image-20240416111610806

模式切换

  • 虚拟机 → Hypervisor
    • VS mode → HS mode(先进入M mode,再由M mode 转发给HS mode)
    • 敏感指令触发
  • Hypervisor → 虚拟机
    • HS mode → VS mode
    • sret指令触发

上下文切换

  • 为VS-mode提供VS CSR
  • 虚拟CPU调度时,同样需要从内存中保存和加载相应的寄存器

2级mmu 地址转换

使用stap 与hgatp 寄存器完成2级 地址转换, 原理一样
va->ipa (guest os) satp
ipa -> pa (host os) hgatp

中断

hedeleg 虚拟异常代理寄存器

hideleg 虚拟中断代理寄存器

默认状态下, 在各级代理寄存器未设置时, 所有的trap 和 中断都被指向到 M 模式的trap (即mtvec 指定的入口函数处), 在指定了 medelegmdieleg后, 相应bit位的trap 和 中断 指向到 HS 模式的trap (即stvec 指定的入口函数处), 进一步, 在指定了 hedeleghideleg 后, 相应bit位的trap 和 中断指向到 VS 模式下的trap(即vstvec 指定的入口函数处)

image-20240416111616014

hedeleg 中, 9-11 bit, 20-23 bit 是readonly的, 只能是0

第0, 3, 8, 12, 13, 15 为推荐设置bit

image-20240416111619570

hideleg 中, 0-15 中, 只有10, 6, 2 能被设置. 当hideleg中, bit 10被设置后, 10号中断来了后, 被代理到VS-mode后, code 10 会被自动转换为 code 9; 同样的 6号中断被自动转换为 5, 2号中断被自动转换为 1号. 这样做的目的是为guest os 中的 kernel 不用进行额外的修改来适配虚拟机.

除此之外, HS模式可以使用hvip寄存器,来向VS模式注入虚拟的中断。

timer

提供htimedelta htimedeltah 寄存器, 在VS VU 模式下访问timer 寄存器会返回真实的time与htimedelta的和. 通过hideleg 代理实现guest timer的中断, 不需要陷入VMM.

io模拟

为了虚拟数据吞吐,HS模式可以使用“陷入-模拟”法。即在访问内存映射外设对应的地址时,产生相应的中断,通过模拟外设的运行来实现后续的过程。这种方式可以模拟PLIC外设、VirtIO外设和其它一些软件模拟的吞吐外设。

iommu 同 ARM SMMU 技术一样提供了设备直通能力, 提供了DMA 重映射和中断重映射机制, 某个外设可以被guest 独占, guest os 访问外设的流程如host os 一样, 无需陷入VMM.

RISCV iommu 最近才有正式的文档发布, 距离开源实现还需要一些时间.

image-20240416111624137

​ no-iommu

image-20240416111627418

​ with iommu

ARM Vs RISCV 硬件虚拟化辅助

image-20240416111631093

背景

  • Read the fucking source code! –By 鲁迅
  • A picture is worth a thousand words. –By 高尔基

说明:

  1. Kernel 版本:4.14
  2. ARM64 处理器
  3. 使用工具:Source Insight 3.5, Visio

1. 概述

从本文开始,将会针对 PCIe 专题来展开,涉及的内容包括:

  1. PCI/PCIe 总线硬件;
  2. Linux PCI 驱动核心框架;
  3. Linux PCI Host 控制器驱动;

不排除会包含 PCIe 外设驱动模块,一切随缘。

作为专题的第一篇,当然会先从硬件总线入手。
进入主题前,先讲点背景知识。
在 PC 时代,随着处理器的发展,经历了几代 I/O 总线的发展,解决的问题都是 CPU 主频提升与外部设备访问速度的问题:

  1. 第一代总线包含ISAEISAVESAMicro Channel等;
  2. 第二代总线包含PCIAGPPCI-X等;
  3. 第三代总线包含PCIemPCIem.2等;

PCIe(PCI Express)是目前 PC 和嵌入式系统中最常用的高速总线,PCIe 在 PCI 的基础上发展而来,在软件上 PCIe 与 PCI 是后向兼容的,PCI 的系统软件可以用在 PCIe 系统中。

本文会分两部分展开,先介绍 PCI 总线,然后再介绍 PCIe 总线,方便在理解上的过渡,开始旅程吧。

2. PCI Local Bus

2.1 PCI 总线组成

  • PCI总线(Peripheral Component Interconnect,外部设备互联),由 Intel 公司提出,其主要功能是连接外部设备;
  • PCI Local Bus,PCI 局部总线,局部总线技术是 PC 体系结构发展的一次变革,是在ISA总线CPU总线之间增加的一级总线或管理层,可将一些高速外设,如图形卡、硬盘控制器等从ISA总线上卸下,而通过局部总线直接挂接在 CPU 总线上,使之与高速CPU总线相匹配。PCI 总线,指的就是PCI Local Bus

先来看一下 PCI Local Bus 的系统架构图:

image-20240416110614230

从图中看,与 PCI 总线相关的模块包括:

  1. Host Bridge,比如 PC 中常见的North Bridge(北桥)
    图中处理器、Cache、内存子系统通过 Host Bridge 连接到 PCI 上,Host Bridge 管理 PCI 总线域,是联系处理器和 PCI 设备的桥梁,完成处理器与 PCI 设备间的数据交换。其中数据交换,包含处理器访问PCI设备的地址空间PCI设备使用DMA机制访问主存储器,在 PCI 设备用 DMA 访问存储器时,会存在 Cache 一致性问题,这个也是 Host Bridge 设计时需要考虑的;
    此外,Host Bridge 还可选的支持仲裁机制,热插拔等;

  2. PCI Local Bus
    PCI 总线,由 Host Bridge 或者 PCI-to-PCI Bridge 管理,用来连接各类设备,比如声卡、网卡、IDE 接口等。可以通过 PCI-to-PCI Bridge 来扩展 PCI 总线,并构成多级总线的总线树,比如图中的PCI Local Bus #0PCI Local Bus #1两条 PCI 总线就构成一颗总线树,同属一个总线域;

  3. PCI-To-PCI Bridge
    PCI桥,用于扩展 PCI 总线,使采用 PCI 总线进行大规模系统互联成为可能,管理下游总线,并转发上下游总线之间的事务;

  4. PCI Device
    PCI 总线中有三类设备:PCI 从设备,PCI 主设备,桥设备。
    PCI 从设备:被动接收来自 Host Bridge 或者其他 PCI 设备的读写请求;
    PCI 主设备:可以通过总线仲裁获得 PCI 总线的使用权,主动向其他 PCI 设备或主存储器发起读写请求;
    桥设备:管理下游的 PCI 总线,并转发上下游总线之间的总线事务,包括PCI桥PCI-to-ISA桥PCI-to-Cardbus桥等。

2.2 PCI 总线信号定义

PCI 总线是一条共享总线,可以挂接多个 PCI 设备,PCI 设备通过一系列信号与 PCI 总线相连,包括:地址 / 数据信号、接口控制信号、仲裁信号、中断信号等。如下图:

image-20240416110619128

  • 左侧红色框里表示的是 PCI 总线必需的信号,而右侧蓝色框里表示的是可选的信号;
  • AD[31:00]:地址与数据信号复用,在传送时第一个时钟周期传送地址,下一个时钟周期传送数据;
  • C/BE[3:0]#:PCI 总线命令与字节使能信号复用,在地址周期中表示的是 PCI 总线命令,在数据周期中用于字节选择,可以进行单字节、字、双字访问;
  • PAR:奇偶校验信号,确保AD[31:00]C/BE[3:0]#传递的正确性;
  • Interface Control:接口控制信号,主要作用是保证数据的正常传递,并根据 PCI 主从设备的状态,暂停、终止或者正常完成总线事务:
    • FRAME#:表示 PCI 总线事务的开始与结束;
    • IRDY#:信号由 PCI 主设备驱动,信号有效时表示 PCI 主设备数据已经 ready;
    • TRDY#:信号由目标设备驱动,信号有效时表示目标设备数据已经 ready;
    • STOP#:目标设备请求主设备停止当前总线事务;
    • DEVSEL#:PCI 总线的目标设备已经准备好;
    • IDSEL:PCI 总线在配置读写总线事务时,使用该信号选择 PCI 目标设备;
  • Arbitration:仲裁信号,由REQ#GNT#组成,与 PCI 总线的仲裁器直接相连,只有 PCI 主设备需要使用该组信号,每条 PCI 总线上都有一个总线仲裁器;
  • Error Reporting:错误信号,包括PERR#奇偶校验错误和SERR系统错误;
  • System:系统信号,包括时钟信号和复位信号;

看一下C/BE[3:0]都有哪些命令吧:

image-20240416110624548

2.3 PCI 事务模型

PCI 使用三种模型用于数据的传输:

image-20240416110646652

  1. Programmed I/O:通过 IO 读写访问 PCI 设备空间;
  2. DMA:PIO 的方式比较低效,DMA 的方式可以直接去访问主存储器而无需 CPU 干预,效率更高;
  3. Peer-to-peer:两台 PCI 设备之间直接传送数据;

2.4 PCI 总线地址空间映射

PCI 体系架构支持三种地址空间:

image-20240416110651082

  1. memory空间
    针对 32bit 寻址,支持 4G 的地址空间,针对 64bit 寻址,支持 16EB 的地址空间;

  2. I/O空间
    PCI 最大支持 4G 的 IO 空间,但受限于 x86 处理器的 IO 空间(16bits 带宽),很多平台将 PCI 的 IO 地址空间限定在 64KB;

  3. 配置空间
    x86 CPU 可以直接访问memory空间I/O空间,而配置空间则不能直接访问;
    每个 PCI 功能最多可以有 256 字节的配置空间;
    PCI 总线在进行配置的时候,采用 ID 译码方式,使用设备的 ID 号,包括Bus NumberDevice NumberFunction NumberRegister Number,每个系统支持 256 条总线,每条总线支持 32 个设备,每个设备支持 8 个功能,由于每个功能最多有 256 字节的配置空间,因此总的配置空间大小为:256B * 8 * 32 * 256 = 16M;

    有必要再进一步介绍一下配置空间:
    x86 CPU 无法直接访问配置空间,通过 IO 映射的数据端口和地址端口间接访问 PCI 的配置空间,其中地址端口映射到0CF8h - 0CFBh,数据端口映射到0CFCh - 0CFFh

    image-20240416110655409

    • 图为配置地址寄存器构成,PCI 的配置过程分为两步:
      1. CPU 写 CF8h 端口,其中写的内容如图所示,BUS,Device,Function 能标识出特定的设备功能,Doubleword 来指定配置空间的具体某个寄存器;
      2. CPU 可以 IO 读写 CFCh 端口,用于读取步骤 1 中的指定寄存器内容,或者写入指定寄存器内容。这个过程有点类似于通过 I2C 去配置外接芯片;

    那具体的配置空间寄存器都是什么样的呢?每个功能 256Byte,前边 64Byte 是 Header,剩余的 192Byte 支持可选功能。有种类型的 PCI 功能:Bridge 和 Device,两者的 Header 都不一样。

    • Bridge

      image-20240416110659912

    • Device

      image-20240416110703859

配置空间中有个寄存器字段需要说明一下:Base Address Register,也就是BAR空间,当 PCI 设备的配置空间被初始化后,该设备在 PCI 总线上就会拥有一个独立的 PCI 总线地址空间,这个空间就是BAR空间BAR空间可以存放 IO 地址空间,也可以存放存储器地址空间。

  • PCI 总线取得了很大的成功,但随着 CPU 的主频不断提高,PCI 总线的带宽也捉襟见肘。此外,它本身存在一些架构上的缺陷,面临一系列挑战,包括带宽、流量控制、数据传送质量等;
  • PCIe 应运而生,能有效解决这些问题,所以 PCIe 才是我们的主角;

3. PCI Express

3.1 PCIe 体系结构

先看一下 PCIe 架构的组成图:

image-20240416110708127

  • Root Complex:CPU 和 PCIe 总线之间的接口可能会包含几个模块(处理器接口、DRAM 接口等),甚至可能还会包含芯片,这个集合就称为Root Complex,它作为 PCIe 架构的根,代表 CPU 与系统其它部分进行交互。广义来说,Root Complex可以认为是 CPU 和 PCIe 拓扑之间的接口,Root Complex会将 CPU 的 request 转换成 PCIe 的 4 种不同的请求(Configuration、Memory、I/O、Message);
  • Switch:从图中可以看出,Swtich提供扇出能力,让更多的 PCIe 设备连接在 PCIe 端口上;
  • Bridge:桥接设备,用于去连接其他的总线,比如 PCI 总线或 PCI-X 总线,甚至另外的 PCIe 总线;
  • PCIe Endpoint:PCIe 设备;
  • 图中白色的小方块代表Downstream端口,灰色的小方块代表Upstream端口;

前文提到过,PCIe 在软件上保持了后向兼容性,那么在 PCIe 的设计上,需要考虑在 PCI 总线上的软件视角,比如Root Complex的实现可能就如下图所示,从而看起来与 PCI 总线相差无异:

image-20240416110712560

  • Root Complex 通常会实现一个内部总线结构和多个桥,从而扇出到多个端口上;
  • Root Complex 的内部实现不需要遵循标准,因此都是厂家 specific 的;

Switch的实现可能如下图所示:

image-20240416110715854

  • Switch 就是一个扩展设备,所以看起来像是各种桥的连接路由;

3.2 PCIe 数据传输

image-20240416110719784

  • 与 PCI 总线不同(PCI 设备共享总线),PCIe 总线使用端到端的连接方式,互为接收端和发送端,全双工,基于数据包的传输;
  • 物理底层采用差分信号(PCI 链路采用并行总线,而 PCIe 链路采用串行总线),一条 Lane 中有两组差分信号,共四根信号线,而 PCIe Link 可以由多条 Lane 组成,可以支持 1、2、4、8、12、16、32 条;

PCIe 规范定义了分层的架构设计,包含三层:

image-20240416110723864

  1. Transaction 层

    • 负责 TLP 包(Transaction Layer Packet)的封装与解封装,此外还负责 QoS,流控、排序等功能;
  2. Data Link 层

    • 负责 DLLP 包(Data Link Layer Packet)的封装与解封装,此外还负责链接错误检测和校正,使用 Ack/Nak 协议来确保传输可靠;
  3. Physical 层

    • 负责Ordered-Set包的封装与解封装,物理层处理 TLPs、DLLPs、Ordered-Set 三种类型的包传输;

数据包的封装与解封装,与网络包的创建与解析很类似,如下图:

image-20240416110727997

  • 封装的时候,在 Payload 数据前添加各种包头,解析时是一个逆向的过程;

来一个更详细的 PCIe 分层图:

image-20240416110731440

3.3 PCIe 设备的配置空间

为了兼容 PCI 软件,PCIe 保留了 256Byte 的配置空间,如下图:

image-20240416110735220

此外,在这个基础上将配置空间扩展到了 4KB,还进行了功能的扩展,比如 Capability、Power Management、MSI 中断等:

image-20240416110749946

  • 扩展后的区域将使用 MMIO 的方式进行访问;

草草收场吧,对 PCI 和 PCIe 有一些轮廓上的认知了,可以开始 Source Code 的软件分析了,欲知详情、下回分解!

背 景

  • Read the fucking source code! –By 鲁迅
  • A picture is worth a thousand words. –By 高尔基

说明:

  1. Kernel 版本:4.14
  2. ARM64 处理器
  3. 使用工具:Source Insight 3.5, Visio

1. 概述

  • 本文将分析 Linux PCI 子系统的框架,主要围绕 Linux PCI 子系统的初始化以及枚举过程分析;
  • 如果对具体的硬件缺乏了解,建议先阅读上篇文章《Linux PCI驱动框架分析(一)》

话不多说,直接开始。

2. 数据结构

image-20240416110922011

  • PCI 体系结构的拓扑关系如图所示,而图中的不同数据结构就是用于来描述对应的模块;
  • Host Bridge 连接 CPU 和 PCI 系统,由struct pci_host_bridge描述;
  • struct pci_dev描述 PCI 设备,以及 PCI-to-PCI 桥设备;
  • struct pci_bus用于描述 PCI 总线,struct pci_slot用于描述总线上的物理插槽;

来一张更详细的结构体组织图:

image-20240416110925629

  • 总体来看,数据结构对硬件模块进行了抽象,数据结构之间也能很便捷的构建一个类似 PCI 子系统物理拓扑的关系图;
  • 顶层的结构为pci_host_bridge,这个结构一般由 Host 驱动负责来初始化创建;
  • pci_host_bridge指向 root bus,也就是编号为 0 的总线,在该总线下,可以挂接各种外设或物理 slot,也可以通过 PCI 桥去扩展总线;

3. 流程分析

3.1 设备驱动模型

Linux PCI 驱动框架,基于 Linux 设备驱动模型,因此有必要先简要介绍一下,实际上 Linux 设备驱动模型也是一个大的 topic,先挖个坑,有空再来填。来张图吧:

image-20240416110928852

  • 简单来说,Linux 内核建立了一个统一的设备模型,分别采用总线、设备、驱动三者进行抽象,其中设备与驱动都挂在总线上,当有新的设备注册或者新的驱动注册时,总线会去进行匹配操作(match函数),当发现驱动与设备能进行匹配时,就会执行 probe 函数的操作;
  • 从数据结构中可以看出,bus_type会维护两个链表,分别用于挂接向其注册的设备和驱动,而match函数就负责匹配检测;
  • 各类驱动框架也都是基于图中的机制来实现,在这之上进行封装,比如 I2C 总线框架等;
  • 设备驱动模型中,包含了很多kset/kobject等内容,建议去看看之前的文章《linux设备模型之kset/kobj/ktype分析》
  • 好了,点到为止,感觉要跑题了,强行拉回来。

3.2 初始化

既然说到了设备驱动模型,那么首先我们要做的事情,就是先在内核里边创建一个 PCI 总线,用于挂接 PCI 设备和 PCI 驱动,我们的实现来到了pci_driver_init()函数:

image-20240416110932256

  • 内核在 PCI 框架初始化时会调用pci_driver_init()来创建一个 PCI 总线结构(全局变量pci_bus_type),这里描述的 PCI 总线结构,是指驱动匹配模型中的概念,PCI 的设备和驱动都会挂在该 PCI 总线上;
  • pci_bus_type的函数操作接口也能看出来,pci_bus_match用来检查设备与驱动是否匹配,一旦匹配了就会调用pci_device_probe函数,下边针对这两个函数稍加介绍;

3.2.1 pci_bus_match

image-20240416110935169

  • 设备或者驱动注册后,触发pci_bus_match函数的调用,实际会去比对vendordevice等信息,这个都是厂家固化的,在驱动中设置成PCI_ANY_ID就能支持所有设备;
  • 一旦匹配成功后,就会去触发pci_device_probe的执行;

3.2.2 pci_device_probe

image-20240416110938431

  • 实际的过程也是比较简单,无非就是进行匹配,一旦匹配上了,直接调用驱动程序的 probe 函数,写过驱动的同学应该就比较清楚后边的流程了;

3.3 枚举

  • 我们还是顺着设备驱动匹配的思路继续开展;
  • 3.2 节描述的是总线的创建,那么本节中的枚举,显然就是设备的创建了;
  • 所谓设备的创建,就是在 Linux 内核中维护一些数据结构来对硬件设备进行描述,而硬件的描述又跟上文中的数据结构能对应上;

枚举的入口函数:pci_host_probe

image-20240416110941635

  • 设备的扫描从pci_scan_root_bus_bridge开始,首先需要先向系统注册一个host bridge,在注册的过程中需要创建一个root bus,也就是bus 0,在pci_register_host_bridge函数中,主要是一系列的初始化和注册工作,此外还为总线分配资源,包括地址空间等;
  • pci_scan_child_bus开始,从bus 0向下扫描并添加设备,这个过程由pci_scan_child_bus_extend来完成;
  • pci_scan_child_bus_extend的流程可以看出,主要有两大块:
    1. PCI 设备扫描,从循环也能看出来,每条总线支持 32 个设备,每个设备支持 8 个功能,扫描完设备后将设备注册进系统,pci_scan_device 的过程中会去读取 PCI 设备的配置空间,获取到 BAR 的相关信息,细节不表了;
    2. PCI 桥设备扫描,PCI 桥是用于连接上一级 PCI 总线和下一级 PCI 总线的,当发现有下一级总线时,创建子结构,并再次调用pci_scan_child_bus_extend的函数来扫描下一级的总线,从这个过程看,就是一个递归过程。
  • 从设备的扫描过程看,这是一个典型的 DFS(Depth First Search)过程,熟悉数据结构与算法的同学应该清楚,这就类似典型的走迷宫的过程;

如果你对上述的流程还不清楚,再来一张图:

image-20240416110947931

  • 图中的数字代表的就是扫描的过程,当遍历到 PCI 桥设备的时候,会一直穷究到底,然后再返回来;
  • 当枚举过程结束后,系统中就已经维护了 PCI 设备的各类信息了,在设备驱动匹配模型中,总线和设备都已经具备了,剩下的就是写个驱动了;

背 景

  • Read the fucking source code! –By 鲁迅
  • A picture is worth a thousand words. –By 高尔基

说明:

  1. Kernel 版本:4.14
  2. ARM64 处理器
  3. 使用工具:Source Insight 3.5, Visio

1. 概述

先回顾一下 PCIe 的架构图:

image-20240416111001038

  • 本文将讲 PCIe Host 的驱动,对应为Root Complex部分,相当于 PCI 的Host Bridge部分;
  • 本文会选择 Xilinx 的nwl-pcie来进行分析;
  • 驱动的编写整体偏简单,往现有的框架上套就可以了,因此不会花太多笔墨,点到为止;

2. 流程分析

  • 但凡涉及到驱动的分析,都离不开驱动模型的介绍,驱动模型的实现让具体的驱动开发变得更容易;
  • 所以,还是回顾一下上篇文章提到的驱动模型:Linux 内核建立了一个统一的设备模型,分别采用总线、设备、驱动三者进行抽象,其中设备与驱动都挂在总线上,当有新的设备注册或者新的驱动注册时,总线会去进行匹配操作(match函数),当发现驱动与设备能进行匹配时,就会执行 probe 函数的操作;

image-20240416111004708

  • 《Linux PCI驱动框架分析(二)》中提到过 PCI 设备、PCI 总线和 PCI 驱动的创建,PCI 设备和 PCI 驱动挂接在 PCI 总线上,这个理解很直观。针对 PCIe 的控制器来说,同样遵循设备、总线、驱动的匹配模型,不过这里的总线是由虚拟总线platform总线来替代,相应的设备和驱动分别为platform_deviceplatform_driver

那么问题来了,platform_device是在什么时候创建的呢?那就不得不提到Device Tree设备树了。

2.1 Device Tree

  • 设备树用于描述硬件的信息,包含节点各类属性,在 dts 文件中定义,最终会被编译成 dtb 文件加载到内存中;
  • 内核会在启动过程中去解析 dtb 文件,解析成device_node描述的Device Tree
  • 根据device_node节点,创建platform_device结构,并最终注册进系统,这个也就是 PCIe Host 设备的创建过程;

我们看看 PCIe Host 的设备树内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
pcie: pcie@fd0e0000 {
compatible = "xlnx,nwl-pcie-2.11";
status = "disabled";
#address-cells = <3>;
#size-cells = <2>;
#interrupt-cells = <1>;
msi-controller;
device_type = "pci";

interrupt-parent = <&gic>;
interrupts = <0 118 4>,
<0 117 4>,
<0 116 4>,
<0 115 4>, /* MSI_1 [63...32] */
<0 114 4>; /* MSI_0 [31...0] */
interrupt-names = "misc", "dummy", "intx", "msi1", "msi0";
msi-parent = <&pcie>;

reg = <0x0 0xfd0e0000 0x0 0x1000>,
<0x0 0xfd480000 0x0 0x1000>,
<0x80 0x00000000 0x0 0x1000000>;
reg-names = "breg", "pcireg", "cfg";
ranges = <0x02000000 0x00000000 0xe0000000 0x00000000 0xe0000000 0x00000000 0x10000000 /* non-prefetchable memory */
0x43000000 0x00000006 0x00000000 0x00000006 0x00000000 0x00000002 0x00000000>;/* prefetchable memory */
bus-range = <0x00 0xff>;

interrupt-map-mask = <0x0 0x0 0x0 0x7>;
interrupt-map = <0x0 0x0 0x0 0x1 &pcie_intc 0x1>,
<0x0 0x0 0x0 0x2 &pcie_intc 0x2>,
<0x0 0x0 0x0 0x3 &pcie_intc 0x3>,
<0x0 0x0 0x0 0x4 &pcie_intc 0x4>;

pcie_intc: legacy-interrupt-controller {
interrupt-controller;
#address-cells = <0>;
#interrupt-cells = <1>;
};
};

关键字段描述如下:

  • compatible:用于匹配 PCIe Host 驱动;
  • msi-controller:表示是一个 MSI(Message Signaled Interrupt)控制器节点,这里需要注意的是,有的 SoC 中断控制器使用的是 GICv2 版本,而 GICv2 并不支持 MSI,所以会导致该功能的缺失;
  • device-type:必须是"pci"
  • interrupts:包含 NWL PCIe 控制器的中断号;
  • interrupts-namemsi1, msi0用于 MSI 中断,intx用于旧式中断,与interrupts中的中断号对应;
  • reg:包含用于访问 PCIe 控制器操作的寄存器物理地址和大小;
  • reg-name:分别表示Bridge registersPCIe Controller registers, Configuration space region,与reg中的值对应;
  • ranges:PCIe 地址空间转换到 CPU 的地址空间中的范围;
  • bus-range:PCIe 总线的起始范围;
  • interrupt-map-maskinterrupt-map:标准 PCI 属性,用于定义 PCI 接口到中断号的映射;
  • legacy-interrupt-controller:旧式的中断控制器;

2.2 probe 流程

  • 系统会根据 dtb 文件创建对应的 platform_device 并进行注册;
  • 当驱动与设备通过compatible字段匹配上后,会调用 probe 函数,也就是nwl_pcie_probe

image-20240416111009578

看一下nwl_pcie_probe函数:

image-20240416111013477

  • 通常 probe 函数都是进行一些初始化操作和注册操作:

    1. 初始化包括:数据结构的初始化以及设备的初始化等,设备的初始化则需要获取硬件的信息(比如寄存器基地址,长度,中断号等),这些信息都从 DTS 而来;
    2. 注册操作主要是包含中断处理函数的注册,以及通常的设备文件注册等;
  • 针对 PCI 控制器的驱动,核心的流程是需要分配并初始化一个pci_host_bridge结构,最终通过这个bridge去枚举 PCI 总线上的所有设备;

  • devm_pci_alloc_host_bridge:分配并初始化一个基础的pci_hsot_bridge结构;

  • nwl_pcie_parse_dt:获取 DTS 中的寄存器信息及中断信息,并通过irq_set_chained_handler_and_data设置intx中断号对应的中断处理函数,该处理函数用于中断的级联;

  • nwl_pcie_bridge_init:硬件的 Controller 一堆设置,这部分需要去查阅 Spec,了解硬件工作的细节。此外,通过devm_request_irq注册misc中断号对应的中断处理函数,该处理函数用于控制器自身状态的处理;

  • pci_parse_request_of_pci_ranges:用于解析 PCI 总线的总线范围和总线上的地址范围,也就是 CPU 能看到的地址区域;

  • nwl_pcie_init_irq_domainmwl_pcie_enable_msi与中断级联相关,下个小节介绍;

  • pci_scan_root_bus_bridge:对总线上的设备进行扫描枚举,这个流程在Linux PCI驱动框架分析(二)中分析过。brdige结构体中的pci_ops字段,用于指向 PCI 的读写操作函数集,当具体扫描到设备要读写配置空间时,调用的就是这个函数,由具体的 Controller 驱动实现;

2.3 中断处理

PCIe 控制器,通过 PCIe 总线连接各种设备,因此它本身充当一个中断控制器,级联到上一层的中断控制器(比如 GIC),如下图:

image-20240416111017752

  • PCIe 总线支持两种中断的处理方式:
    1. Legacy Interrupt:总线提供INTA#, INTB#, INTC#, INTD#四根中断信号,PCI 设备借助这四根信号使用电平触发方式提交中断请求;
    2. MSI(Message Signaled Interrupt) Interrupt:基于消息机制的中断,也就是往一个指定地址写入特定消息,从而触发一个中断;

针对两种处理方式,NWL PCIe驱动中,实现了两个irq_chip,也就是两种方式的中断控制器:

image-20240416111020774

  • irq_domain对应一个中断控制器(irq_chip),irq_domain负责将硬件中断号映射到虚拟中断号上;
  • 来一张旧图吧,具体文章可以去参考中断子系统相关文章;

image-20240416111024251

再来看一下nwl_pcie_enable_msi函数:

image-20240416111028689

  • 在该函数中主要完成的工作就是设置级联的中断处理函数,级联的中断处理函数中最终会去调用具体的设备的中断处理函数;

所以,稍微汇总一下,作为两种不同的中断处理方式,套路都是一样的,都是创建irq_chip中断控制器,为该中断控制器添加irq_domain,具体设备的中断响应流程如下:

  1. 设备连接在 PCI 总线上,触发中断时,通过 PCIe 控制器充当的中断控制器路由到上一级控制器,最终路由到 CPU;
  2. CPU 在处理 PCIe 控制器的中断时,调用它的中断处理函数,也就是上文中提到过的nwl_pcie_leg_handlernwl_pcie_msi_handler_high,和nwl_pcie_leg_handler_low
  3. 在级联的中断处理函数中,调用chained_irq_enter进入中断级联处理;
  4. 调用irq_find_mapping找到具体的 PCIe 设备的中断号;
  5. 调用generic_handle_irq触发具体的 PCIe 设备的中断处理函数执行;
  6. 调用chained_irq_exit退出中断级联的处理;

2.4 总结

  • PCIe 控制器驱动,各家的 IP 实现不一样,驱动的差异可能会很大,单独分析一个驱动毕竟只是个例,应该去掌握背后的通用框架;
  • 各类驱动,大体都是硬件初始化配置,资源申请注册,核心是处理与硬件的交互(一般就是中断的处理),如果需要用户来交互的,则还需要注册设备文件,实现一堆file_operation操作函数集;
  • 好吧,我个人不太喜欢分析某个驱动,草草收场了;

下篇开始,继续回归到虚拟化,期待一下吧。

背景

  • Read the fucking source code! –By 鲁迅
  • A picture is worth a thousand words. –By 高尔基

说明:

  1. KVM 版本:5.9.1
  2. QEMU 版本:5.0.0
  3. 工具:Source Insight 3.5, Visio
  4. 文章同步在博客园:https://www.cnblogs.com/LoyenWang/

1. 概述

先从操作系统的角度来看一下 timer 的作用吧:

image-20240416110453083

通过 timer 的中断,OS 实现的功能包括但不局限于上图:

  • 定时器的维护,包括用户态和内核态,当指定时间段过去后触发事件操作,比如 IO 操作注册的超时定时器等;
  • 更新系统的运行时间、wall time 等,此外还保存当前的时间和日期,以便能通过time()等接口返回给用户程序,内核中也可以利用其作为文件和网络包的时间戳;
  • 调度器在调度任务分配给 CPU 时,也会去对 task 的运行时间进行统计计算,比如 CFS 调度,Round-Robin 调度等;
  • 资源使用统计,比如系统负载的记录等,此外用户使用 top 命令也能进行查看;

timer 就像是系统的脉搏,重要性不言而喻。ARMv8 架构处理器提供了一个 Generic Timer,与 GIC 类似,Generic Timer 在硬件上也支持了虚拟化,减少了软件模拟带来的 overhead。

本文将围绕着 ARMv8 的 timer 虚拟化来展开。

2. ARMv8 Timer 虚拟化

2.1 Generic Timer

看一下 ARMv8 架构下的 CPU 内部图:

image-20240416110458595

  • Generic Timer提供了一个系统计数器,用于测量真实时间的消逝;
  • Generic Timer支持虚拟计数器,用于测量虚拟的时间消逝,一个虚拟计数器对应一个虚拟机;
  • Timer可以在特定的时间消逝后触发事件,可以设置成count-up计数或者count-down计数;

来看一下Generic Timer的简图:

image-20240416110502169

或者这个:

image-20240416110505795

  • System Counter位于Always-on电源域,以固定频率进行系统计数的增加,System Counter的值会广播给系统中的所有核,所有核也能有一个共同的基准了,System Counter的频率范围为 1-50MHZ,系统计数值的位宽在 56-64bit 之间;
  • 每个核有一组 timer,这些 timer 都是一些比较器,与System Counter广播过来的系统计数值进行比较,软件可以配置固定时间消逝后触发中断或者触发事件;
  • 每个核提供的 timer 包括:1)EL1 Physical timer;2)EL1 Virtual timer;此外还有在 EL2 和 EL3 下提供的 timer,具体取决于 ARMv8 的版本;
  • 有两种方式可以配置和使用一个 timer:1)CVAL(comparatoer)寄存器,通过设置比较器的值,当System Count >= CVAL时满足触发条件;2)TVAL寄存器,设置TVAL寄存器值后,比较器的值CVAL = TVAL + System Counter,当System Count >= CVAL时满足触发条件,TVAL是一个有符号数,当递减到 0 时还会继续递减,因此可以记录 timer 是在多久之前触发的;
  • timer 的中断是私有中断PPI,其中EL1 Physical Timer的中断号为 30,EL1 Virtual Timer的中断号为 27;
  • timer 可以配置成触发事件产生,当 CPU 通过WFE进入低功耗状态时,除了使用SEV指令唤醒外,还可以通过Generic Timer产生的事件流来唤醒;

2.2 虚拟化支持

Generic Timer的虚拟化如下图:

image-20240416110509533

  • 虚拟的 timer,同样也有一个 count 值,计算关系:Virtual Count = Physical Count - <offset>,其中 offset 的值放置在CNTVOFF寄存器中,CNTPCT/CNTVCT分别用于记录当前物理 / 虚拟的 count 值;
  • 如果 EL2 没有实现,则将 offset 设置为 0,,物理的计数器和虚拟的计数器值相等;
  • Physical Timer直接与System counter进行比较,Virtual TimerPhysical Timer的基础上再减去一个偏移;
  • Hypervisor 负责为当前调度运行的 vCPU 指定对应的偏移,这种方式使得虚拟时间只会覆盖 vCPU 实际运行的那部分时间;

示例如下:

image-20240416110513314

  • 6ms 的时间段里,每个 vCPU 运行 3ms,Hypervisor 可以使用偏移寄存器来将 vCPU 的时间调整为其实际的运行时间;

3. 流程分析

3.1 初始化

先简单看一下数据结构吧:

image-20240416110517205

  • 在 ARMv8 虚拟化中,使用struct arch_timer_cpu来描述Generic Timer,从结构体中也能很清晰的看到层次结构,创建 vcpu 时,需要去初始化 vcpu 架构相关的字段,其中就包含了 timer;
  • struct arch_timer_cpu包含了两个 timer,分别对应物理 timer 和虚拟 timer,此外还有一个高精度定时器,用于 Guest 处在非运行时的计时工作;
  • struct arch_timer_context用于描述一个 timer 需要的内容,包括了几个字段用于存储寄存器的值,另外还描述了中断相关的信息;

初始化分为两部分:

  1. 架构相关的初始化,针对所有的 CPU,在 kvm 初始化时设置:

  • kvm_timer_hyp_init函数完成相应的初始化工作;
  • arch_timer_get_kvm_info从 Host Timer 驱动中去获取信息,主要包括了虚拟中断号和物理中断号,以及 timecounter 信息等;
  • vtimer 中断设置包括:判断中断的触发方式(只支持电平触发),注册中断处理函数kvm_arch_timer_handler,设置中断到 vcpu 的 affinity 等;
  • ptimer 中断设置与 vtimer 中断设置一样,同时它的中断处理函数也是kvm_arch_timer_handler,该处理函数也比较简单,最终会调用kvm_vgic_inject_irq函数来完成虚拟中断注入给 vcpu;
  • cpuhp_setup_state用来设置 CPU 热插拔时 timer 的响应处理,而在kvm_timer_starting_cpu/kvm_timer_dying_cpu两个函数中实现的操作就是中断的打开和关闭,仅此而已;
  1. vcpu 相关的初始化,在创建 vcpu 时进行初始化设置:

image-20240416110543191

  • 针对 vcpu 的 timer 相关初始化比较简单,回到上边那张数据结构图看一眼就明白了,所有的初始化工作都围绕着struct arch_timer_cpu结构体;
  • vcpu_timer:用于获取 vcpu 包含的struct arch_timer_cpu结构;
  • vcpu_vtimer/vcpu_ptimer:用于获取struct arch_timer_cpu结构体中的struct arch_timer_context,分别对应 vtimer 和 ptimer;
  • update_vtimer_cntvoff:用于更新 vtimer 中的 cntvoff 值,读取物理 timer 的 count 值,更新 VM 中所有 vcpu 的 cntvoff 值;
  • hrtimer_init:用于初始化高精度定时器,包含有三个,struct arch_timer_cpu结构中有一个bg_timer,vtimer 和 ptimer 所对应的struct arch_timer_context中分别对应一个;
  • kvm_bg_timer_expirebg_timer的到期执行函数,当需要调用kvm_vcpu_block让 vcpu 睡眠时,需要先启动bg_timerbg_timer到期时再将 vcpu 唤醒;
  • kvm_hrtimer_expire:vtimer 和 ptimer 的到期执行函数,最终通过调用kvm_timer_update_irq来向 vcpu 注入中断;

3.2 用户层访问

可以从用户态对 vtimer 进行读写操作,比如 Qemu 中,流程如下:

image-20240416110538838

  • 用户态创建完 vcpu 后,可以通过 vcpu 的文件描述符来进行寄存器的读写操作;
  • 以 ARM 为例,ioctl 通过KVM_SET_ONE_REG/KVM_GET_ONE_REG将最终触发寄存器的读写;
  • 如果操作的是 timer 的相关寄存器,则通过kvm_arm_timer_set_regkvm_arm_timer_get_reg来完成;
  • 读写的寄存器包括虚拟 timer 的 CTL/CVAL,以及物理 timer 的 CTL/CVAL 等;

3.3 Guest 访问

Guest 对 Timer 的访问,涉及到系统寄存器的读写,将触发异常并 Trap 到 Hyp 进行处理,流程如下:

image-20240416110534774

  • Guest OS 访问系统寄存器时,Trap 到 Hypervisor 进行处理;
  • Hypervisor 对异常退出进行处理,如果发现是访问系统寄存器造成的异常,则调用kvm_handle_sys_reg来处理;
  • kvm_handle_sys_reg:调用emulate_sys_reg来对系统寄存器进行模拟,在该函数中首先会查找访问的是哪一个寄存器,然后再去调用相应的回调函数;
  • kvm 中维护了struct sys_reg_desc sys_reg_descs[]系统寄存器的描述表,其中struct sys_reg_desc结构体中包含了对该寄存器操作的函数指针,用于指向最终的操作函数,比如针对 Timer 的kvm_arm_timer_write_sysreg/kvm_arm_timer_read_sysreg读写操作函数;
  • Timer 的读写操作函数,主要在kvm_arm_timer_read/kvm_arm_timer_write中完成,实现的功能就是根据物理的 count 值和 offset 来计算等;

timer 的虚拟化还是比较简单,就此打住了。

PS:

按计划,接下里该写 IO 虚拟化了,然后紧接着 Qemu 的源码相关分析。不过,在写 IO 虚拟化之前,我会先去讲一下 PCIe 的驱动框架,甚至可能还会去研究一下网络,who knows,反正这些也都是 IO 相关。
Any way,I will be back soon!

参考

《AArch64 Programmer's Guides Generic Timer》
《Arm Architecture Reference Manual》

背景

  • Read the fucking source code! –By 鲁迅
  • A picture is worth a thousand words. –By 高尔基

说明:

  1. KVM 版本:5.9.1
  2. QEMU 版本:5.0.0
  3. 工具:Source Insight 3.5, Visio
  4. 文章同步在博客园:https://www.cnblogs.com/LoyenWang/

1. 概述

  • 本文围绕 ARMv8 CPU 的虚拟化展开;
  • 本文会结合 Qemu + KVM 的代码分析,捋清楚上层到底层的脉络;
  • 本文会提供一个 Sample Code,用于类比 Qemu 和 KVM 的关系,总而言之,大同小异,大题小做,大道至简,大功告成,大恩不言谢;

先来两段前戏。

1.1 CPU 工作原理

AI 的世界,程序的执行不再冰冷,CPU 对a.out说,hello啊,world已经ok啦,下来return吧!

既然要说 CPU 的虚拟化,那就先简要介绍一下 CPU 的工作原理:

  • CPU 的根本任务是执行指令,我们常说的取指-译码-执行-访存-写回,就是典型的指令 Pipeline 操作;
  • 从 CPU 的功能出发,可以简要分成三个逻辑模块:
    1. Control Unit:CPU 的指挥中心,协调数据的移动;
    2. ALU:运算单元,执行 CPU 内部所有的计算;
    3. Register:寄存器和Cache,都算是 CPU 内部的存储单元,其中寄存器可用于存储需要被译码和执行的指令、数据、地址等;
  • CPU 从内存中读取指令进行译码并执行,执行的过程中需要去访问内存中的数据,CPU 内部的寄存器可以暂存中间的指令和数据等信息,通常说的 CPU 的context指的就是 CPU 寄存器值;

在硬件支持虚拟化之前,Qemu 纯软件虚拟化方案,是通过tcg(tiny code generator)的方式来进行指令翻译,翻译成 Host 处理器架构的指令来执行。硬件虚拟化技术,是让虚拟机能直接执行在 Host CPU 上,让 Host CPU 直接来执行虚拟机,结合 CPU 的实际工作原理,应该怎么来理解呢?来张图:

  • CPU 通过pc寄存器获取下一条执行指令,进行取指译码执行等操作,因此给定 CPU 一个 Context,自然就能控制其执行某些代码;
  • CPU 的虚拟化,最终目标让虚拟机执行在 CPU 上,无非也是要进行 CPU 的 Context 切换,控制 CPU 去执行对应的代码,下文会进一步阐述;

既然都讲 CPU 了,那就捎带介绍下 ARMv8 的寄存器吧:

  1. 通用寄存器:

  • 图中描述的是EL3以下,AArch32AArch64寄存器对应关系;
  • AArch64中,总共 31 个通用寄存器,64bit 的为 X0-X30,32bit 的为 W0-W30;
  1. 特殊用途寄存器:

  • 这些特殊用途的寄存器,主要分为三种:1)存放异常返回地址的ELR_ELx;2)各个 EL 的栈指针SP_ELx;3)CPU 的状态相关寄存器;
  1. CPU 的状态PSTATE

  • CPU 的状态在AArch32时是通过CPSR来获取,在AArch64中,使用PSTATEPSTATE不是一个寄存器,它表示的是保存当前 CPU 状态信息的一组寄存器或一些标志信息的统称;

好了,ARMv8 的介绍该打住了,否则要跑偏了。。。

1.2 guest 模式

  • Linux 系统有两种执行模式:kernel 模式与 user 模式,为了支持虚拟化功能的 CPU,KVM 向 Linux 内核提供了 guest 模式,用于执行虚拟机系统非 I/O 的代码;
  • user 模式,对应的是用户态执行,Qemu 程序就执行在 user 模式下,并循环监听是否有 I/O 需要模拟处理;
  • kernel 模式,运行 kvm 模块代码,负责将 CPU 切换到 VM 的执行,其中包含了上下文的 load/restore;
  • guest 模式,本地运行 VM 的非 I/O 代码,在某些异常情况下会退出该模式,Host OS 开始接管;

好了啦,前戏结束,开始直奔主题吧。

2. 流程分析

不管你说啥,我上来就是一句中国万岁,对不起,跑题了。我上来就是一张 Qemu 初始化流程图:

  • 看过 Qemu 源代码的人可能都有种感觉,一开始看好像摸不到门框,这图简要画了下关键模块的流程;
  • Qemu 的源代码,后续的文章会详细介绍,本文只 focus 在vcpu相关部分;

除了找到了qemu_init_vcpu的入口,这张图好像跟本文的 vcpu 的虚拟化关系不是很大,不管了,就算是给后续的 Qemu 分析打个广告吧。

2.1 vcpu 的创建

2.1.1 qemu 中 vcpu 创建

  • Qemu 初始化流程图中,找到了qemu_init_vcpu的入口,顺着这个qemu_init_vcpu就能找到与底层 KVM 模块交互的过程;
  • Qemu 中为每个 vcpu 创建了一个线程,操作设备节点来创建和初始化 vcpu;

所以,接力棒甩到了 KVM 内核模块。

2.1.2 kvm 中 vcpu 创建

来一张前文的图:

  • 前文中分析过,系统在初始化的时候会注册字符设备驱动,设置好了各类操作函数集,等待用户层的ioctl来进行控制;
  • Qemu中设置KVM_CREATE_VCPU,将触发kvm_vm_ioctl_create_vcpu的执行,完成 vcpu 的创建工作;

  • 在底层中进行 vcpu 的创建工作,主要是分配一个kvm_vcpu结构,并且对该结构中的字段进行初始化;
  • 其中有一个用于与应用层进行通信的数据结构struct kvm_run,分配一页内存,应用层会调用 mmap 来进行映射,并且会从该结构中获取到虚拟机的退出原因;
  • kvm_arch_vcpu_create主要完成体系架构相关的初始化,包括 timer,pmu,vgic 等;
  • create_hyp_mappingskvm_vcpu结构体建立映射,以便在Hypervisor模式下能访问该结构;
  • create_vcpu_fd注册了kvm_vcpu_fops操作函数集,针对 vcpu 进行操作,Qemu中设置KVM_ARM_VCPU_INIT,将触发kvm_arch_vcpu_ioctl_vcpu_init的执行,完成的工作主要是 vcpu 的核心寄存器,系统寄存器等的 reset 操作,此外还包含了上层设置下来的值,放置在struct kvm_vcpu_init中;

2.2 vcpu 的执行

2.2.1 qemu 中 vcpu 的执行

  • Qemu中为每一个 vcpu 创建一个用户线程,完成了 vcpu 的初始化后,便进入了 vcpu 的运行,而这是通过kvm_cpu_exec函数来完成的;
  • kvm_cpu_exec函数中,调用kvm_vcpu_ioctl(,KVM_RUN,)来让底层的物理 CPU 进行运行,并且监测 VM 的退出,而这个退出原因就是存在放在kvm_run->exit_reason中,也就是上文中提到过的应用层与底层交互的机制;

2.2.2 kvm 中 vcpu 的执行

用户层通过KVM_RUN命令,将触发 KVM 模块中kvm_arch_vcpu_ioctl_run函数的执行:

  • vcpu 最终是要放置在物理 CPU 上执行的,很显然,我们需要进行 context 的切换:保存好 Host 的 Context,并切换到 Guest 的 Context 去执行,最终在退出时再恢复回 Host 的 Context;
  • __guest_enter函数完成最终的 context 切换,进入 Guest 的执行,当 Guest 退出时,fixup_guest_exit将会处理exit_code,判断是否继续返回 Guest 执行;
  • 当最终 Guest 退出到 Host 时,Host 调用handle_exit来处理异常退出,根据kvm_get_exit_handler去查询异常处理函数表对应的处理函数,最终进行执行处理;

3. Sample Code

  • 上文已经将 Qemu+KVM 的 CPU 的虚拟化大概的轮廓已经介绍了,方方面面,问题不大;
  • 来一段 Sample Code 类比 Qemu 和 KVM 的关系,在 Ubuntu16.04 系统上进行测试;

简要介绍一下:

  1. tiny_kernel.S,相当于 Qemu 中运行的 Guest OS,完成的功能很简单,没错,就是Hello, world打印;
  2. tiny_qemu.c,相当于 Qemu,用于加载 Guest 到 vCPU 上运行,最终通过 kvm 放到物理 CPU 上运行;

鲁迅在 1921 年的时候,说过这么一句话:Talk is cheap, show me the code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
- tiny_kernel.S:

start:
/* Hello */
mov $0x48, %al
outb %al, $0xf1
mov $0x65, %al
outb %al, $0xf1
mov $0x6c, %al
outb %al, $0xf1
mov $0x6c, %al
outb %al, $0xf1
mov $0x6f, %al
outb %al, $0xf1
mov $0x2c, %al
outb %al, $0xf1

/* world */
mov $0x77, %al
outb %al, $0xf1
mov $0x6f, %al
outb %al, $0xf1
mov $0x72, %al
outb %al, $0xf1
mov $0x6c, %al
outb %al, $0xf1
mov $0x64, %al
outb %al, $0xf1

mov $0x0a, %al
outb %al, $0xf1

hlt

- `tiny_qemu.c`:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <assert.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/kvm.h>
#include <sys/mman.h>

#define KVM_DEV "/dev/kvm"
#define TINY_KERNEL_FILE "./tiny_kernel.bin"
#define PAGE_SIZE 0x1000

int main(void)
{
int kvm_fd;
int vm_fd;
int vcpu_fd;
int tiny_kernel_fd;
int ret;
int mmap_size;

struct kvm_sregs sregs;
struct kvm_regs regs;
struct kvm_userspace_memory_region mem;
struct kvm_run *kvm_run;
void *userspace_addr;

/* open kvm device */
kvm_fd = open(KVM_DEV, O_RDWR);
assert(kvm_fd > 0);

/* create VM */
vm_fd = ioctl(kvm_fd, KVM_CREATE_VM, 0);
assert(vm_fd >= 0);

/* create VCPU */
vcpu_fd = ioctl(vm_fd, KVM_CREATE_VCPU, 0);
assert(vcpu_fd >= 0);

/* open tiny_kernel binary file */
tiny_kernel_fd = open(TINY_KERNEL_FILE, O_RDONLY);
assert(tiny_kernel_fd > 0);
/* map 4K into memory */
userspace_addr = mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
assert(userspace_addr > 0);
/* read tiny_kernel binary into the memory */
ret = read(tiny_kernel_fd, userspace_addr, PAGE_SIZE);
assert(ret >= 0);

/* set user memory region */
mem.slot = 0;
mem.flags = 0;
mem.guest_phys_addr = 0;
mem.memory_size = PAGE_SIZE;
mem.userspace_addr = (unsigned long)userspace_addr;
ret = ioctl(vm_fd, KVM_SET_USER_MEMORY_REGION, &mem);
assert(ret >= 0);

/* get kvm_run */
mmap_size = ioctl(kvm_fd, KVM_GET_VCPU_MMAP_SIZE, NULL);
assert(mmap_size >= 0);
kvm_run = (struct kvm_run *)mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, vcpu_fd, 0);
assert(kvm_run >= 0);

/* set cpu registers */
ret = ioctl(vcpu_fd, KVM_GET_SREGS, &sregs);
assert(ret >= 0);
sregs.cs.base = 0;
sregs.cs.selector = 0;
ret = ioctl(vcpu_fd, KVM_SET_SREGS, &sregs);
memset(®s, 0, sizeof(struct kvm_regs));
regs.rip = 0;
ret = ioctl(vcpu_fd, KVM_SET_REGS, ®s);
assert(ret >= 0);

/* vcpu run */
while (1) {
ret = ioctl(vcpu_fd, KVM_RUN, NULL);
assert(ret >= 0);

switch(kvm_run->exit_reason) {
case KVM_EXIT_HLT:
printf("----KVM EXIT HLT----\n");
close(kvm_fd);
close(tiny_kernel_fd);
return 0;
case KVM_EXIT_IO:
putchar(*(((char *)kvm_run) + kvm_run->io.data_offset));
break;
default:
printf("Unknow exit reason: %d\n", kvm_run->exit_reason);
break;
}
}

return 0;
}

为了表明我没有骗人,上一张在 Ubuntu16.04 的虚拟机上运行的结果图吧:

背景

  • Read the fucking source code! –By 鲁迅
  • A picture is worth a thousand words. –By 高尔基

说明:

  1. KVM 版本:5.9.1
  2. QEMU 版本:5.0.0
  3. 工具:Source Insight 3.5, Visio
  4. 文章同步在博客园:https://www.cnblogs.com/LoyenWang/

1. 概述

  • 从本文开始将开始source code的系列分析了;
  • KVM作为内核模块,可以认为是一个中间层,向上对接用户的控制,向下对接不同架构的硬件虚拟化支持;
  • 本文主要介绍体系架构初始化部分,以及向上的框架;

image-20240416110125240

2. KVM 初始化

  • 贝多芬曾经说过,一旦你找到了代码的入口,你就扼住了软件的咽喉;
  • 我们的故事,从module_init(arm_init)开始,代码路径:arch/arm64/kvm/arm.c

老规矩,先来一张图(图片中涉及到的红色框函数,都是会展开描述的):

image-20240416110142495

  • 内核的功能模块,基本上的套路就是:1)完成模块初始化,向系统注册;2)响应各类请求,这种请求可能来自用户态,也可能来自异常响应等;
  • kvm的初始化,在kvm_init中完成,既包含了体系结构相关的初始化设置,也包含了各类回调函数的设置,资源分配,以及设备注册等,只有当初始化完成后,才能响应各类请求,比如创建虚拟机等;
    1. 回调函数设置:cpuhp_setup_state_nocall与 CPU 的热插拔相关,register_reboot_notifer与系统的重启相关,register_syscore_ops与系统的休眠唤醒相关,而这几个模块的回调函数,最终都会去调用体系结构相关的函数去打开或关闭Hypervisor
    2. 资源分配:kmem_cache_create_usercopykvm_async_pf_init都是创建slab缓存,用于内核对象的分配;
    3. kvm_vfio_ops_initVFIO是一个可以安全将设备I/O、中断、DMA 导出到用户空间的框架,后续在将 IO 虚拟化时再深入分析;
  • 图片中红色的两个函数,是本文分析的内容,其中kvm_arch_init与前文ARMv8硬件虚拟化支持紧密相关,而misc_register与上层操作紧密相关;

2.1 kvm_arch_init

  • It's a big topic, I'll try to put it in a nutshell.
  • 这部分内容,设计 ARMv8 体系结构,建议先阅读《Linux虚拟化KVM-Qemu分析(二)之ARMv8虚拟化》
  • 红色框的函数是需要进一步展开讲述的;

image-20240416110149365

  • is_hyp_mode_available用于判断 ARMv8 的Hyp模式是否可用,实际是通过判断__boot_cpu_mode的值来完成,该值是在arch/arm64/kernel/head.S中定义,在启动阶段会设置该值:

image-20240416110157924

  • is_kernel_in_hyp_mode,通过读取 ARMv8 的CurrentEL,判断是否为CurrentEL_EL2
  • ARM 架构中,SVE的实现要求VHE也要实现,这个可以从arch/arm64/Kconfig中看到,SVE的模块编译:depends on !KVM || ARM64_VHESVE(scalable vector extension),是AArch64下一代的SIMD(single instruction multiple data)指令集,用于加速高性能计算。其中SIMD如下:

image-20240416110206914

  • init_common_resources,用于设置IPA的地址范围,将其限制在系统能支持的物理地址范围之内。stage 2页表依赖于stage 1页表代码,需要遵循一个条件:Stage 1的页表级数 >= Stage 2的页表级数;

2.1.1 init_hyp_mode

image-20240416110216437

  • 放眼望去,init_hyp_mode解决的问题就是各种映射,最终都会调用到__create_hyp_mappings,先来解决这个映射问题:

image-20240416110224472

  • 看过之前内存管理子系统的同学,应该熟悉这个页表映射建立的过程,基本的流程是给定一个虚拟地址区间和物理地址,然后从pgd开始逐级往下去建立映射。ARMv8 架构在实际映射过程中,P4D这一级页表并没有使用。

让我们继续回到init_hyp_mode的正题上来,这个函数完成了PGD页表的分配,完成了IDMAP代码段的映射,完成了其他各种段的映射,完成了异常向量表的映射,等等。此外,再补充几点内容:

  1. ARMv8异常向量表

image-20240416110233444

  • ARMv8 架构的 AArch64 执行态中,每种 EL 都有 16 个 entry,分为四类:Synchronous,IRQ,FIQ,SError。以系统启动时设置 hypervisor 的异常向量表__hyp_stub_vectors为例:

image-20240416110239895

  • 当从不同的Exception Level触发异常时,根据执行状态,去选择对应的handler处理,比如上图中只有el1_sync有效,也就是在EL1状态触发EL2时跳转到该函数;
  1. pushsection/popsection
  • init_hyp_mode函数中,完成各种段的映射,段的定义放置在vmlinux.lds.S中,比如hyp.idmap.text

image-20240416110245685

  • 可以通过pushsection/popsection来在目标文件中来添加一个段,并指定段的属性,比如 “ax” 代表可分配和可执行,这个在汇编代码中经常用到,比如hyp-init.S中,会将代码都放置在hyp.idmap.text中:

image-20240416110253334

  • 除了pushsection/popsection外,通过#define __hyp_text __section(.hyp.text) notrace __noscs的形式也能将代码放置在指定的段中;
  1. Hypervisor相关寄存器
  • 讲几个关键的相关寄存器:
    1)sctlr_el2(System Control Register):可以用于控制 EL2 的 MMU 和 Cache 相关操作;
    2)ttbr0_el2(Translation Table Base Register 0):用于存放页表的基地址,上文中提到分配的hyp_pgd就需要设置到该寄存器中;
    3)vbar_el2(Vector Base Address Register):用于存放异常向量表的基地址;

我们需要先明确几点:

  1. Hyp模式下要执行的代码,需要先建立起映射;
  2. 映射IDMAP代码段和其他代码段,明确这些段中都有哪些函数,这个可以通过pushsection/popsection以及__hyp_text宏可以看出来;
  3. 最终的目标是需要建立好页表映射,并安装好异常向量表;

貌似内容比较零碎,最终的串联与谜题留在下一小节来解答。

2.1.2 init_subsystems

先看一下函数的调用流程:

image-20240416110301469

  • VGICtimer,以及电源管理相关模块在本文中暂且不深入分析了,本节主要关心cpu_hyp_reinit的功能;
  • 绿色框中的函数,会陷入到EL2进行执行;

看图中有好几次异常向量表的设置,此外,还有页表基地址、栈页的获取与设置等,结合上一小节的各类映射,是不是已经有点迷糊了,下边这张图会将这些内容串联起来:

image-20240416110306248

  • 在整个异常向量表创建的过程中,涉及到三个向量表:__hyp_stub_vectors__kvm_hyp_init, __kvm_call_hyp,这些代码都是汇编实现;
  • 在系统启动过程中 (arch/arm64/kernel/head.S),调用到el2_setup函数,在该函数中设置了一个临时的异常向量表,也就是先打一个桩,这个从名字也可以看出来,该异常向量表中仅实现了el2_synchandler处理函数,可以应对两种异常:1)设置新的异常向量表;2)重置异常向量表,也就是设置回__hyp_stub_vectors
  • kvm初始化时,调用了__hyp_set_vectors来设置新的异常向量表:__kvm_hyp_init。这个向量表中只实现了__do_hyp_init的处理函数,也就是只能用来对Hyp模式进行初始化。上文提到过idmap段,这个代码就放置在idmap段,以前分析内存管理子系统时也提到过idmap,为什么需要这个呢?idmap: identity map,也就是物理地址和虚拟地址是一一映射的,防止 MMU 在使能前后代码不能执行;
  • __kvm_call_hyp函数,用于在Hyp模式下执行指定的函数,在cpu_hyp_reinit函数中调用了该函数,传递的参数包括了新的异常向量表地址,页表基地址,Hyp的栈地址,per-CPU偏移等,最终会调用__do_hyp_init函数完成相应的设置。

到此,页表和异常向量表的设置算是完成了。

2.2 misc_register

misc_register用于注册字符设备驱动,在kvm_init函数中调用此函数完成注册,以便上层应用程序来使用kvm模块

image-20240416110317124

  • 字符设备的注册分为三级,分别代表kvmvmvcpu,上层最终使用底层的服务都是通过ioctl函数来操作;
  • kvm:代表 kvm 内核模块,可以通过kvm_dev_ioctl来管理 kvm 版本信息,以及 vm 的创建等;
  • vm:虚拟机实例,可以通过kvm_vm_ioctl函数来创建vcpu,设置内存区间,分配中断等;
  • vcpu:代表虚拟的 CPU,可以通过kvm_vcpu_ioctl来启动或暂停 CPU 的运行,设置 vcpu 的寄存器等;

Qemu的使用为例:

  1. 打开/dev/kvm设备文件;
  2. ioctl(xx, KVM_CREATE_VM, xx)创建虚拟机对象;
  3. ioctl(xx, KVM_CREATE_VCPU, xx)为虚拟机创建 vcpu 对象;
  4. ioctl(xx, KVM_RUN, xx)让 vcpu 运行起来;

3. 总结

本文主要从两个方向来介绍了kvm_init

  1. 底层的体系结构相关的初始化,主要涉及的就是EL2的相关设置,比如各个段的映射,异常向量表的安装,页表基地址的设置等,当把这些准备工作做完后,才能在硬件上去支持虚拟化的服务请求;
  2. 字符设备注册,设置好各类ioctl的函数,上层应用程序可以通过字符设备文件,来操作底层的 kvm 模块。这部分内容深入的分析,留到后续的文章再展开了;

实际在看代码过程中,一度为很多细节绞尽乳汁,对不起,是绞尽脑汁,每有会意,便欣然忘食,一文也无法覆盖所有内容,草率了。

本文主要分析 linux kernel 中 SMMUv3 的代码 (drivers/iommu/arm-smmu-v3.c)
linux kernel 版本是 linux 5.7, 体系结构是 aarch64

smmu 的位置

SMMU 的作用是把 CPU 提交给设备的 VA 地址,直接作为设备发出的地址,变成正确的物理地址,访问到物理内存上。
和 mmu 不同的是,一个 smmu 可以有多个设备连着,他们的页表不可能复用,SMMU 用 stream id 作区分。
一个设备有多个进程,所以 smmu 单元也要支持多页表,smmu 使用 substream id 区分多进程的页表。

smmu 的设备节点定义

在讨论 smmu 的代码前,先看下 smmu 的设备节点是怎么定义的:
Example:

1
2
3
4
5
6
7
8
9
10
11
12
smmu@2b400000 {
compatible = "arm,smmu-v3";
reg = <0x0 0x2b400000 0x0 0x20000>;
interrupts = <GIC_SPI 74 IRQ_TYPE_EDGE_RISING>,
<GIC_SPI 75 IRQ_TYPE_EDGE_RISING>,
<GIC_SPI 77 IRQ_TYPE_EDGE_RISING>,
<GIC_SPI 79 IRQ_TYPE_EDGE_RISING>;
interrupt-names = "eventq", "priq", "cmdq-sync", "gerror";
dma-coherent;
#iommu-cells = <1>;
msi-parent = <&its 0xff0000>;
};

compatible: 用于匹配 smmu 驱动。
reg:smmu 设备的物理基地址。
interrupts: 描述与中断名称对应的 smmu 中断源,上述分别对应中断类型,中断号以及中断触发方式。
interrupt-names: 中断名称。
eventq,当 event queue 从空变为非空状态时上报中断。
priq, 当 pri queue 从空变为非空状态时上报中断。
cmdq-sync, command queue 中 CMDQ_SYNC 命令完成时产生中断。
gerror,event 记录到 event queue 过程中产生的错误会记录在 SMMU_GERROR 寄存器中,并产生中断。
combined,组合中断,需要硬件支持,如果提供了组合中断,则将优先使用组合中断。
dma-coherent:表示设备通过 smmu 进行的 DMA 访问是否 cache coherent 的,假设 DMA 把外设的数据搬运到内存的某个位置,cpu 去读那段地址,因为 cache 命中了,读到的还是旧的值,这就是 cache 的不 coherent。
#iommu-cells: 一个 cell 代表一个 streamid, smmu-v3 必须定义为 1。
msi-parent:指定 msi 中断控制器。

SMMU 结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct arm_smmu_domain {
struct arm_smmu_device *smmu;
struct mutex init_mutex; /* Protects smmu pointer */

struct io_pgtable_ops *pgtbl_ops;
bool non_strict;
atomic_t nr_ats_masters;

enum arm_smmu_domain_stage stage;
union {
struct arm_smmu_s1_cfg s1_cfg;
struct arm_smmu_s2_cfg s2_cfg;
};

struct iommu_domain domain;

struct list_head devices;
spinlock_t devices_lock;
};

arm_smmu_device: 指定 smmu 设备
io_pgtable_ops: io 页表映射定义的一系列操作
non_strict: smmu non-strict 模式,在该补丁集中引入 add non-strict mode support for arm-smmu-v3,
主要是为了解决开启 smmu 后,频繁的 unmap,需要频繁的 invalid tlb 带来的性能损失, 所以不在每一次 unmap 后都进行 tlb invalidate 操作,而是累计一定次数或者时间后执行 invalid all 操作,但这样是有一定的安全风险(页表虽然释放了但是还是在 tlb 中有残留,可能被利用到)。可以通过启动参数控制。
nr_ats_masters: ats 的设备数量,enable_ats 时数量 + 1, disable ats 时数量减 1
arm_smmu_domain_stage: 代表 smmu 支持的方式,支持 stage1 的转换,stage2 的转换,stage1 + stage2 的转换,以及 bypass 模式。
arm_smmu_s1_cfg: stage1 转换需要的数据结构
arm_smmu_s2_cfg: stage2 转换需要的数据结构

smmu 驱动初始化

从 smmu 驱动的 probe 函数开始分析

1
2
3
4
5
6
7
8
+->arm_smmu_device_probe() //smmu设备驱动probe入口函数
+-> arm_smmu_device_dt_probe() //smmu设备树解析
+-> platform_get_irq_byname() // smmu设备中断解析
+-> arm_smmu_device_hw_probe() // smmu硬件规格探测
+-> arm_smmu_init_structures() //smmu 数据结构初始化
+-> arm_smmu_device_reset() // smmu设备复位, 硬件初始化配置
+-> iommu_device_register() // iommu设备注册
+-> arm_smmu_set_bus_ops() // 给支持的总线设置bus->iommu_ops

对 probe 中调用的这些函数进行详细分析

(1)arm_smmu_device_dt_probe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int arm_smmu_device_dt_probe(struct platform_device *pdev,
struct arm_smmu_device *smmu)
{
int ret = -EINVAL;
if (of_property_read_u32(dev->of_node, "#iommu-cells", &cells)) ---- (a)
dev_err(dev, "missing #iommu-cells property\n");
else if (cells != 1)
dev_err(dev, "invalid #iommu-cells value (%d)\n", cells);
else
ret = 0;

parse_driver_options(smmu); ----- (b)

if (of_dma_is_coherent(dev->of_node)) ---- (c)
smmu->features |= ARM_SMMU_FEAT_COHERENCY;

return ret;
}

a. 读取设备树,看 smmu 的设备节点定义中 #iommu-cells 是否为 1, 如果不为 1 则直接 bypass 掉 smmu

b. parse_driver_options, 主要解析 smmu 是否有需要规避的硬件 bug

c. 解析 smmu 设备中的 dma-coherent 属性

(2) platform_get_irq_byname

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Interrupt lines */

irq = platform_get_irq_byname_optional(pdev, "combined");
if (irq > 0)
smmu->combined_irq = irq;
else {
irq = platform_get_irq_byname_optional(pdev, "eventq");
if (irq > 0)
smmu->evtq.q.irq = irq;

irq = platform_get_irq_byname_optional(pdev, "priq");
if (irq > 0)
smmu->priq.q.irq = irq;

irq = platform_get_irq_byname_optional(pdev, "gerror");
if (irq > 0)
smmu->gerr_irq = irq;
}

分别获取 dts 节点中定义的 “combined”, “eventq”, “priq”, “gerror” 中断号

(3) arm_smmu_device_hw_probe

该函数主要探测 smmu 设备的硬件规格,主要是通过读 SMMU 的 IDR0,IDR1,IDR5 寄存器确认

SMMU_IDR0:

域段 offset 作用
ST_LEVEL 28: 27 确认 stream table 格式是线性 table 还是 2-level table
TERM_MODEL 26 fault 的处理方式,
STALL_MODEL 25: 24 确认是否是 stall mode。 该模式下 smmu 会暂停引发 stall 的 transaction, 然后 stall,之后根据软件的 commad 是 resume 还是 stall_term 来决定 stall 命令是 retry 还是 terminate。当前只允许 4 种 fault 类型被 stall: F_TRANSLATION, F_ACCESS, F_ADDR_SIZE. F_PERMISSION.【spec 5.5 Fault configuration (A,R,S bits)】
TTENDIAN 22: 21 确认 traslation table 支持的大小端模式
CD2L 19 确认是否支持 2-level 的 CD table
VMW 17 用于控制 vmid wildcards 的功能和范围。 vmid wildcard, vmid 的模糊匹配,是为了方便 tlb 无效化, 两种 tlb 无效化的方式:command 和广播 tlb 无效都会使用到 vmid wildcards
VMID16 18 确认是否支持 16bit VMID。 每一个虚拟机都被分配一个 ID 号,这个 ID 号用于标记某个特定的 TLB 项属于哪一个 VM。每一个 VM 有它自己的 ASID 空间。例如两个不同的 VMs 同时使用 ASID 5,但指的是不同的东西。对于虚拟机而言,通常 VMID 会结合 ASID 同时使用。
PRI 16 确认是否支持 page request interface。 属于 pcie 硬件特性,PCIe 设备可以发出缺页请求,SMMU 硬件在解析到缺页请求后可以直接将缺页请求写入 PRI queueu, 软件在建立好页表后,可以通过 CMD queue 发送 PRI response 给 PCIe 设备。[ Linux SVA 特性分析]
SEV 14 确认是否支持 WFE wake-up 事件的产生。 当 SEV == 1 时,command queue 从满到非满状态会触发 WFE wake-up event。 此外,当 CMD_SYNC 完成且要求 SIG_SEV 时也会产生 WFE wake-up event
MSI 13 确认是否支持 MSI 消息中断
ASID16 12 确认是否支持 16bit ASID.。 在 TLB 的表项中,每个项都有一个 ASID,当切换进程的时候,TLB 中可以存在多个进程的页表项, 不再需要清空 TLB,因为 B 进程用不了里面 A 进程的页表项,可以带来性能提升。[TLB flush 操作]
ATS 10 Address Translation Service, 也是 pcie 的硬件特性,有 ATS 能力的 PCIE,自带地址翻译功能,如果它要发出一个地址,进入 PCIE 总线的时候,一定程度上可以认为就是物理地址。ats 会在设备中缓存 va 对应的 pa, 设备随后使用 pa 做内存访问时无需经过 SMMU 页表转换,可以提高性能。 【PCIe/SMMU ATS analysis note
HTTU 7:6 Hardware Translation Table Update,在访问或写入相关页面后,硬件自动更新相关页面的 Access flag、Dirty state。该特性和 armv8.1 的 tthm 特性一样,在没有 tthm 特性之前,软件去更新页表的 young 和 dirty page, 会有一定的开销。
S1P 1 确认是否支持 stage1 转换,va->pa
S2P 0 确认是否支持 stage2 转换,ipa->pa

SMMU_IDR1:

域段 offset 作用
TABLES_PRESET 30 确认 smmu_strtab_base 和 smmu_strtab_base_cfg 的基地址是否固定, smmu_strtab_base 是 stream table 的物理基地址,smmu_strtab_base_cfg 是 stream table 的配置寄存器
QUEUES_PRESET 29 确认 smmu queue 的基地址是否固定,queue 指的是 smmu event queue, smmu cmd queue, smmu pri queue
CMDQS 25:21 cmd queue 最大 entry 数目, 等于 log2(entries), 最大是 19
EVENTQS 20:16 event queue 的最大 entry 数目,等于 log2(entries), 最大是 19
PRIQS 15:11 pri queue 最大 entry 数目,等于 log2(entries), 最大是 19
SSIDSIZE 10:6 确认硬件支持 substreamID 的 bit 数,范围为【0,20】, 0 表示不支持 substreamid
SIDSIZE 5:0 确认硬件支持 streamID 的 bit 数,范围为【0,32】, 0 表示支持一个 stream

IDR1 主要用来设置 smmu 各个 queue 的 entry 数量, 设置 ssid 和 sid 的 size.

SMMU_IDR5:

域段 offset 作用
STALL_MAX 31:16 smmu 支持的最大未完成 stall 事务数
VAX 11:10 表示 smmu 支持的 va 地址是 48bit 还是 52bit
GRAN64K 6 支持 64KB 翻译粒度, Translation Granule 表示 translation table 的 size 大小, 页表粒度是 smmu 管理的最小地址空间
GRAN16K 5 支持 16KB 翻译粒度
GRAN4K 4 支持 4KB 翻译粒度
OAS 2:0 表示 output address size, 32bit ~ 52bit

IDR5 主要设置 smmu ias(input address size) 和 oas (output address size), ias 代表 IPA,oas 代表 PA。

(4) arm_smmu_init_structures()

smmu 相关的数据结构的内存申请和初始化

1
2
3
4
5
6
7
8
9
10
11
static int arm_smmu_init_structures(struct arm_smmu_device *smmu)
{
int ret;

ret = arm_smmu_init_queues(smmu); ----------------- (a)
if (ret)
return ret;

return arm_smmu_init_strtab(smmu); ----------------- (b)

}

(a) arm_smmu_init_queues() 会初始化三个 queue, 分别为 cmd queue, event queue, pri queue.
SMMU 使用这 3 个队列做基本的事件管理。
event queue 用于记录软件配置错误的状态信息,smmu 将配置错误信息记录到 event queue 中,软件会通过从 event queue 读取配置错误信息,然后进行相应的配置错误处理。
软件使用 command queue 和 smmu 硬件进行交互,软件写命令发送到 command queue 中,smmu 会从 command queue 中读取命令进行处理。
pri queue 需要硬件支持 pri 特性,和 event queue 类似,当有相应硬件事件发生时,硬件把相应的描述符写入 pri queue, 然后上报中断。

(b) arm_smmu_init_strtab

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static int arm_smmu_init_strtab(struct arm_smmu_device *smmu)
{
u64 reg;
int ret;

if (smmu->features & ARM_SMMU_FEAT_2_LVL_STRTAB)
ret = arm_smmu_init_strtab_2lvl(smmu);
else
ret = arm_smmu_init_strtab_linear(smmu);

if (ret)
return ret;

/* Set the strtab base address */
reg = smmu->strtab_cfg.strtab_dma & STRTAB_BASE_ADDR_MASK;
reg |= STRTAB_BASE_RA;
smmu->strtab_cfg.strtab_base = reg;

/* Allocate the first VMID for stage-2 bypass STEs */
set_bit(0, smmu->vmid_map);
return 0;
}

首先确认 SMMU 的 stream table 的组织方式是线性 table 还是 2-level table.
如果是 linear table:

image-20240416105935403

使用 STRTAB_BASE + sid * 64(一个 STE 的大小为 64B)找到 STE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+-> arm_smmu_init_strtab_linear
// 计算stream table的size, 如果使用linear 查找,stream table的size = sid * 64(sid表示有多少个ste, 一个STE的大小为64B)
+-> size = (1 << smmu->sid_bits) * (STRTAB_STE_DWORDS << 3);
// 申请Stream table的内存
+-> strtab = dmam_alloc_coherent()
// 配置stream table(STRTAB_BASE_CFG)的format, 决定stream table的格式是linear
+-> reg = FIELD_PREP(STRTAB_BASE_CFG_FMT, STRTAB_BASE_CFG_FMT_LINEAR);
// 配置stream table(STRTAB_BASE_CFG)的log2size, ste的entry数目是2 ^ log2size
+-> reg |= FIELD_PREP(STRTAB_BASE_CFG_LOG2SIZE, smmu->sid_bits);
// cfg->num_l1_ents对应的是sid, 对SMMU下的所有sid逐一调用arm_smmu_write_strtab_ent
+-> arm_smmu_init_bypass_stes(strtab, cfg->num_l1_ents)
+-> arm_smmu_write_strtab_ent()
// 发送CMDQ_OP_PREFETCH_CFG
+-> arm_smmu_cmdq_issue_cmd()

如果是 2-level table:

image-20240416105955813

先通过 sid 的高位找到 L1_STD(STRTAB_BASE + sid[9:8] * 8, 一个 L1_STD 的大小为 8B), L1_STD 定义了下一级查找的基地址,然后通过 sid 找到具体的 STE(l2ptr + sid[7:0] * 64).

结合代码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
+-> arm_smmu_init_strtab_2lvl()
"计算l1的大小, 一个l1 std的大小为8byte, 对应的l1_std = sid[maxbit:split], maxbit是log2Size - 1, 所以l1的大小等于2 ^ (log2Size - split) * 8 "
+-> l1size = cfg->num_l1_ents * (STRTAB_L1_DESC_DWORDS << 3);
"申请L1 stream table的空间"
+-> strtab = dmam_alloc_coherent()
"配置stream table(STRTAB_BASE_CFG)的format, 决定stream table的格式是2-level"
+-> reg = FIELD_PREP(STRTAB_BASE_CFG_FMT, STRTAB_BASE_CFG_FMT_2LVL);
"配置stream table(STRTAB_BASE_CFG)的log2size,2级ste的entry是2 ^ log2size, l1 std的 entry大小为2 ^ (log2size - split)"
+-> reg |= FIELD_PREP(STRTAB_BASE_CFG_LOG2SIZE, size);
"配置stream table(STRTAB_BASE_CFG)的split, split的值可以被配置为6/8/10, 分别对应l1 std能够指向的最大二级ste的空间为4k/16k/64k"
+-> reg |= FIELD_PREP(STRTAB_BASE_CFG_SPLIT, STRTAB_SPLIT);
" 分配L1STD的内存, 并配置L1 descriptor的SPAN,SPAN表示L2 table包含多少个STE "
+-> arm_smmu_init_l1_strtab()

申请 l1 Stream table 的内存,内存大小为 2 ^ (log2Size - split) * 8
申请 L1 STD 的内存, L1 STD 在 stream table 的索引是 streamID[maxbit: split]
配置完 stream table 的结构和各级大小后,再配置 stream table 的基地址:

1
2
3
	"配置stream table(STRTAB_BASE_CFG)的RA和addr, addr对应的是stream table的物理地址ra为read allocate hint "
+-> reg = smmu->strtab_cfg.strtab_dma & STRTAB_BASE_ADDR_MASK;
+-> reg |= STRTAB_BASE_RA;

至此 stream table 的初始化流程结束

(5) arm_smmu_device_reset

该函数主要是进行 smmu 的硬件配置
主要流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
+-> arm_smmu_device_reset()
"写SMMU_CR0来disable smmu,并通过SMMU_CR0ACK检查CR0是否被clear"
+-> arm_smmu_device_disable()
"配置读取ste和command queue的属性"
+-> writel_relaxed(ARM_SMMU_CR1);
"random crap"
+-> writel_relaxed(ARM_SMMU_CR2);
"配置 ARM_SMMU_STRTAB_BASE 寄存器,分别对应stream table的物理基地址以及格式,大小等"
+->writeq_relaxed(smmu->strtab_cfg.strtab_base, ARM_SMMU_STRTAB_BASE);
+->writel_relaxed(smmu->strtab_cfg.strtab_base_cfg, ARM_SMMU_STRTAB_BASE);
/* 配置cmd queue相关寄存器
* ARM_SMMU_CMDQ_BASE 是配置command queue的基地址
* ARM_SMMU_CMDQ_PROD, 可以表示取出命令的位置
* ARM_SMMU_CMDQ_CONS, 可以表示输入命令的位置
* ARM_SMMU_CMDQ_PROD和ARM_SMMU_CMDQ_CONS初始化时配置为相同的值,都为0
* 通过CMDQ_PROD和CMDQ_CONS, 可以判断command queue是否还有空间
*/
+-> writeq_relaxed(smmu->cmdq.q.q_base, smmu->base + ARM_SMMU_CMDQ_BASE);
+-> writel_relaxed(smmu->cmdq.q.llq.prod, smmu->base + ARM_SMMU_CMDQ_PROD);
+-> writel_relaxed(smmu->cmdq.q.llq.cons, smmu->base + ARM_SMMU_CMDQ_CONS);
"最后配置command queue的en,对command queue进行使能"
+-> enables = CR0_CMDQEN;
"配置event queue相关寄存器, 流程和command queue类似"
+-> config event queue
"如果支持pri, 则配置pri queue相关寄存器, 流程和上面一致"
+-> config pri queue
"申请并使能smmu支持的相关中断(eventq irq, priq irq, gerror irq)"
+-> arm_smmu_setup_irqs()
"enable smmu, 写SMMU_CR0,并通过SMMU_CR0ACK检查CR0是否被enable"
+-> arm_smmu_write_reg_sync(smmu, enables, ARM_SMMU_CR0, ARM_SMMU_CR0ACK);

再着重讲下 smmu 的中断注册:arm_smmu_setup_irqs()

1
2
3
4
5
+-> arm_smmu_setup_irqs()
+-> arm_smmu_setup_unique_irqs()
+-> arm_smmu_setup_msis(smmu);
+-> arm_smmu_write_msi_msg()
+-> devm_request_irq(smmu->dev, irq, arm_smmu_gerror_handler, 0, "arm-smmu-v3-gerror", smmu);

arm_smmu_write_msi_msg() 函数里会去:

配置 MSI 中断的目的地址
配置 MSI 的中断数据
配置 MSI 中断的写地址的属性
配置完成后,当中断产生时,最终会进入中断注册的处理函数, 以 gerror 的中断处理为例:

1
2
3
4
5
6
arm_smmu_gerror_handler()
"读gerrorgerrorrn寄存器,确认gerror中断发生的错误类型"
+-> gerror = readl_relaxed(smmu->base + ARM_SMMU_GERROR);
+-> gerrorn = readl_relaxed(smmu->base + ARM_SMMU_GERRORN);
"完成中断处理后,写gerror和gerrorn对应的的位一致,global中断处理完成"
+-> writel(gerror, smmu->base + ARM_SMMU_GERRORN);

对于 PCI 设备,ATS,
PRI 和 PASID 的概念同时存在于 PCIe 和 SMMU 规范中。对于 ATS 的介绍可以参考这里:
https://blog.csdn.net/scarecrow_byr/article/details/74276940
。简单讲,ATS 特性由设备侧的 ATC 和 SMMU 侧的 ATS 同时支持,其目的是在设备中缓存 va 对应的 pa,设备随后使用 pa 做内存访问时无需经过 SMMU 页表转换,可以提高性能。
PRI(page request interface) 也是需要设备和 SMMU 一起工作,PCIe 设备可以发出缺页请求,SMMU 硬件在解析到缺页请求后可以直接将缺页请求写入 PRI queueu, 软件在建立好页表后,可以通过CMD queue 发送 PRI response 给 PCIe 设备。具体的 ATS 和 PRI 的实现是硬件相关的,目前市面上还没有实现这两个硬件特性的 PCIe 设备,但是我们可以设想一下 ATS 和 PRI 的硬件实现,最好的实现应该是软件透明的,也就是软件配置给设备 DMA 的访问地址是 va.
软件控制 DMA 发起后,硬件先发起 ATC 请求,从 SMMU 请求该 va 对应的 pa,如果 SMMU 里已经有 va 到 pa 的映射,那么设备可以得到 pa,然后设备再用 pa 发起一次内存访问,该访问将直接访问对应 pa 地址,不在 SMMU 做地址翻译;
如果 SMMU 没有 va 到 pa 的映射, 那么设备得到这个消息后会继续向 SMMU 发 PRI 请求,设备得到从 SMMU 来的 PRI response 后发送内存访问请求,该请求就可以在 SMMU 中翻译得到 pa, 最终访问到物理内存。

PRI 请求是基于 PCIe 协议的, 平台设备无法用 PRI 发起缺页请求。实际上,平台设备是无法靠自身发起缺页请求的,SMMU 用 stall 模式支持平台设备的缺页,当一个平台设备的内存访问请求到达 SMMU 后,如果 SMMU 里没有为 va 做到 pa 的映射,硬件会给 SMMU 的 event queue 里写一个信息,SMMU 的 event queue 中断处理里可以做缺页处理,然后 SMMU 可以回信息给设备 (fix me: 请求设备重发,还是 smmu 缺页处理后已经把该访问翻译后送到上游总线)。
实际上, SMMU 使用 event queue 来处理各种错误异常,这里的 stall 模式是借用了 event queue 来处理缺页。

(6) iommu_device_register

注册 iommu 设备, 主要设计一个操作,就是将 smmu 设备添加到 iommu_device_list 中

1
2
3
4
5
6
7
int iommu_device_register(struct iommu_device *iommu)
{
spin_lock(&iommu_device_lock);
list_add_tail(&iommu->list, &iommu_device_list);
spin_unlock(&iommu_device_lock);
return 0;
}

着重讲下和 iommu_device 相关的两个重要数据结构 iommu_group 和 iommu_domain
看下 iommu_device 结构体的定义

1
2
3
4
5
6
struct iommu_device {
struct list_head list;
const struct iommu_ops *ops;
struct fwnode_handle *fwnode;
struct device *dev;
};

iommu_device 中定义了 iommu_ops 以及 struct device,
在 struct device 中,有 iommu_group 的成员,iommu_group 又包含了 iommu_domain。
iommu_device->device->iommu_group->iommu_domain

iommu_domain 的具体定义:

1
2
3
4
5
6
7
8
9
struct iommu_domain {
unsigned type;
const struct iommu_ops *ops;
unsigned long pgsize_bitmap; /* Bitmap of page sizes in use */
iommu_fault_handler_t handler;
void *handler_token;
struct iommu_domain_geometry geometry;
void *iova_cookie;
};

每一个 domain 代表一个具体的设备使用 iommu 的详细 spec
在 arm_smmu_domain 结构体中,又将 arm_smmu_domain 和 iommu_domain 关联, 所以 iommu_ops 指向 SMMU 驱动。所以最终 ARM 是用 arm_smmu_domain 来管理驱动和设备之间的关联的。

iommu_group 的具体定义:[drivers/iommu/iommu.c: iommu_group]

1
2
3
4
5
6
7
8
9
10
11
12
13
struct iommu_group {
struct kobject kobj;
struct kobject *devices_kobj;
struct list_head devices;
struct mutex mutex;
struct blocking_notifier_head notifier;
void *iommu_data;
void (*iommu_data_release)(void *iommu_data);
char *name;
int id;
struct iommu_domain *default_domain;
struct iommu_domain *domain;
};

为什么会有一个 iommu_group 的概念,直接将 device 和 iommu_domain 关联不香吗?
假设我们通过 iommu 提供设备的 DMA 能力,当发起 dma_map 的时候,设备设置了 streamid, 但是多个设备的 streamid 有可能是一样的。 那么这时候修改其中一个设备的页表体系,也就相当于修改了另一个设备的页表体系。所以,修改页表的最小单位不是设备,而是 streamid。
因此,为了避免这种情况,增加了一个 iommu_group 的概念,iommu_group 代表共享同一个 streamid 的一组 device(表述在 / sys/kernel/iommu_group 中)。
有了 iommu_group, 设备发起 dma_map 操作时,会定位 streamid 和 iommu_group, group 定位了 iommu_device 和 iommu_domain,iommu_domain 定位了 asid,这样,硬件要求的全部信息都齐了。
(Linux iommu 和 vfio 概念空间解构)

(7) arm_smmu_set_bus_ops

给 smmu 支持的总线设置 bus->iommu_ops, 让总线具有了 iommu attach 的能力。

1
2
3
4
arm_smmu_set_bus_ops(&arm_smmu_ops)
+-> bus_set_iommu(&pci_bus_type, ops);
+-> bus_set_iommu(&amba_bustype, ops);
+-> bus_set_iommu(&platform_bus_type, ops);

arm_smmu_ops 结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static struct iommu_ops arm_smmu_ops = {
.capable = arm_smmu_capable,
.domain_alloc = arm_smmu_domain_alloc,
.domain_free = arm_smmu_domain_free,
.attach_dev = arm_smmu_attach_dev,
.map = arm_smmu_map,
.unmap = arm_smmu_unmap,
.flush_iotlb_all = arm_smmu_flush_iotlb_all,
.iotlb_sync = arm_smmu_iotlb_sync,
.iova_to_phys = arm_smmu_iova_to_phys,
.add_device = arm_smmu_add_device,
.remove_device = arm_smmu_remove_device,
.device_group = arm_smmu_device_group,
.domain_get_attr = arm_smmu_domain_get_attr,
.domain_set_attr = arm_smmu_domain_set_attr,
.of_xlate = arm_smmu_of_xlate,
.get_resv_regions = arm_smmu_get_resv_regions,
.put_resv_regions = generic_iommu_put_resv_regions,
.pgsize_bitmap = -1UL, /* Restricted during device attach */
};

主要分析 smmu 的两个关键操作:arm_smmu_attach_dev 和 arm_smmu_add_device
arm_smmu_add_device: 将 smmu 设备添加到总线

arm_smmu_add_device()

1
2
3
4
5
6
7
8
9
+-> smmu = arm_smmu_get_by_fwnode(fwspec->iommu_fwnode);
/* for each sid, 如果是2-level ste, 为l2 ste分配内存
*在之前的init_l1_strtab, 已经初始化了L1_std, L1_STD定义了下一级查找的基地址,
* 现在可以通过sid 找到具体的STE(l2ptr + sid[7:0] * 64
* 这个函数先为每一个sid分配L2_STE的内存, 分配完成后在为每一个SID进行cfg配置
*/
+-> arm_smmu_init_l2_strtab()
"将device和group关联起来"
+-> iommu_device_link()

总线扫描发现了设备,总线的发现流程负责调用 iommu_ops(arm_smmu_ops) 给这个设备加上 iommu_group,然后让 iommu_group 指向对应的 iommu 控制器

arm_smmu_attach_dev, 尝试为设备寻找到驱动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
arm_smmu_attach_dev()
" 从iommu_domain 中得到arm_smmu_domain"
+-> smmu_domain = to_smmu_domain(iommu_domain );
"一般情况下smmu_domain->smmu = NULL"
"在arm_smmu_add_device中,我们已经为STE项分配了内存"
+-> arm_smmu_domain_finalise(domain, master);
"分配asid"
+-> asids = arm_smmu_bitmap_alloc()
"根据smmu stage是stage1还是stage2, 如果smmu domain是stage1"
+-> arm_smmu_domain_finalise_s1()
"分配CD table的空间"
+-> arm_smmu_alloc_cd_tables(smmu_domain);
"配置CD descriptor的cfg"
+-> cfg->cd.tcr = FIELD_PREP(CTXDESC_CD_0_XXX)...
"如果smmu domain是stage2, STE已经包含了页表的s2ttb基地址和vmid,结束"
+-> arm_smmu_domain_finalise_s2()
+-> finalise_stage_fn(smmu_domain, master, &pgtbl_cfg);

支持了 2-leveli 的 CD 或 linear 格式的 CD, 方式和 SID 查找 ste 类似。

image-20240416110013071

结合代码分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
static int arm_smmu_alloc_cd_tables(struct arm_smmu_domain *smmu_domain)
{
int ret;
size_t l1size;
size_t max_contexts;
struct arm_smmu_device *smmu = smmu_domain->smmu;
struct arm_smmu_s1_cfg *cfg = &smmu_domain->s1_cfg;
struct arm_smmu_ctx_desc_cfg *cdcfg = &cfg->cdcfg;

max_contexts = 1 << cfg->s1cdmax; -------------- (a)

if (!(smmu->features & ARM_SMMU_FEAT_2_LVL_CDTAB) ||
max_contexts <= CTXDESC_L2_ENTRIES) {
cfg->s1fmt = STRTAB_STE_0_S1FMT_LINEAR; -------- (b)
cdcfg->num_l1_ents = max_contexts;

l1size = max_contexts * (CTXDESC_CD_DWORDS << 3); ------- (c)
} else {
cfg->s1fmt = STRTAB_STE_0_S1FMT_64K_L2;
cdcfg->num_l1_ents = DIV_ROUND_UP(max_contexts,
CTXDESC_L2_ENTRIES);

cdcfg->l1_desc = devm_kcalloc(smmu->dev, cdcfg->num_l1_ents,
sizeof(*cdcfg->l1_desc),
GFP_KERNEL);
if (!cdcfg->l1_desc)
return -ENOMEM;

l1size = cdcfg->num_l1_ents * (CTXDESC_L1_DESC_DWORDS << 3);
}

cdcfg->cdtab = dmam_alloc_coherent(smmu->dev, l1size, &cdcfg->cdtab_dma,
GFP_KERNEL);
if (!cdcfg->cdtab) {
dev_warn(smmu->dev, "failed to allocate context descriptor\n");
ret = -ENOMEM;
goto err_free_l1;
}

return 0;

err_free_l1:
if (cdcfg->l1_desc) {
devm_kfree(smmu->dev, cdcfg->l1_desc);
cdcfg->l1_desc = NULL;
}
return ret;
}

在 CD 的建立过程中,主要涉及到以下几点:
ste.S1Contextptr 中定义了 CD 的基地址, CD 的大小为 64byte
a. 需要配置 ste.S1CDMax, cdmax 为 0 表示这个 ste 只有一个 CD, 不需要使用到 substreamid, 如果 cdmax 不为 0, 那么 CD 的数目是 2 ^ S1CDMax;
b. 需要配置 ste.S1Fmt, 如果是 linear 结构的 CD,CD 的获取方法为 S1ContextPTR + 64 * ssid; 如果是 2-level 结构的 CD, L1CD 的索引为 ssid[s1cdmax - 1: 6], L2CD 的索引为 ssid[5:0]

attach_dev 完成后,如果是 stage1 相关,CD 的结构, 大小和基地址已经成功建立,成功获取 STE 后,可以通过 substreamid 找到 CD(S1ContextPTR + 64 * ssid)。找到的 CD 中包含页表 PTW 需要的 TTBR 寄存器,所以每一个 CD 对应一个页表, 这样一个 SMMU 单元,就可以有多张页表。

总结:
smmu 驱动的初始化流程就是一个探测硬件规格,初始化硬件配置,分配 STD/STE/CD 等空间的过程。

image-20240416110053027

参考资料
Linux iommu 和 vfio 概念空间解构
IHI0070_System_Memory_Management_Unit_Arm_Architecture_Specification

riscv qemu-kvm 框架

image-20240416112007309

arm vs riscv 硬件虚拟化

image-20240416112011154

riscv-aia

riscv 体系下, 中断直通需要 支持 aia 架构的 riscv-imsic

https://github.com/riscv/riscv-aia/

riscv-imsic stable
image-20240416112014887

riscv-imsic release
image-20240416112018975

riscv-iommu

设备直通需要 iommu 组件, riscv 体系下, iommu 属于 non-isa 部分.

Non-ISA specifications do not add new instructions, create or change opcodes, or in any way modify the RISC-V ISA. They do help us to develop an ecosystem around the ISA Specifications.

https://github.com/riscv-non-isa/riscv-iommu/

image-20240416112022820

riscv qemu-kvm 框架

arm vs riscv 硬件虚拟化

riscv-aia

riscv 体系下, 中断直通需要 支持 aia 架构的 riscv-imsic

https://github.com/riscv/riscv-aia/

riscv-imsic stable
image-20240416112108734

riscv-imsic release
image-20240416112103799

riscv-iommu

设备直通需要 iommu 组件, riscv 体系下, iommu 属于 non-isa 部分.

Non-ISA specifications do not add new instructions, create or change opcodes, or in any way modify the RISC-V ISA. They do help us to develop an ecosystem around the ISA Specifications.

https://github.com/riscv-non-isa/riscv-iommu/

image-20240416112059789

设备直通

image-20240416112056260

软硬件架构支撑

  • pcie 支持
  • 中断直通
  • iommu

qemu-kvm 架构中的设备直通都是针对的PCIE设备
qemu 框架中使用vfio 模式支撑 PCIE 设备直通体系.

设备直通包含两方面:

  • 中断直通
  • DMA 重映射

arm 下的PCIE 设备直通

正常的pcie 设备 kvm 注入中断的过程:

  1. QEMU通过对设备ioctl(VFIO_DEVICE_SET_IRQS)将VFIO设备中断与eventfd关联,并对VFIO设备申请中断并填充中断处理函数vfio_msihandler();
  2. QEMU中将guest要求的中断virq与eventfd关联,即当eventfd收到事件时,会往guest OS注入中断,这是通过QEMU对调用ioctl(KVM_IRQFD)实现的;
  3. Guest OS对可以产生MSI/MSIX中断的内存映射区(设备配置空间或设备BAR空间)发起写操作时,会产生VM Exit到QEMU,QEMU将写的数据填写到设备的BAR空间中MSIX对应的Table中,从而触发ITS产生中断;
  4. 当VFIO设备收到中断时,首先触发vfio-pci设备的中断处理函数vfio_msihandler(),它会调用eventfd_signal()向与virq关联的eventfd发送事件,eventfd收到事件后往guest OS注入中断;

而在GICv4 lpi升级到vlpi 后, 上述步骤变为:

  1. QEMU通过对设备ioctl(VFIO_DEVICE_SET_IRQS)将VFIO设备中断与eventfd关联
  2. Guest OS对可以产生MSI/MSIX中断的内存映射区(设备配置空间或设备BAR空间)发起写操作时, 或pcie 外设写MSI/MSIX 中断内存映射区后, guest os 正在运行, 由guest os 自己处理中断; 如guest os不在运行, 则中断由gicv4的doorbell机制, 由doorbell的中断处理函数注入中断给vcpu. 切换vcpu的guest os运行处理中断.

中断直通硬件支撑

riscv-imsic

imsic 中新增了 guest interrupt file 的逻辑, 每一个guest interrupt file 绑定一个物理cpu上的某个 vcpu

软件在申请中断时, 需要写msi 地址, 表明要申请哪个硬件中断号, 绑定的是哪个cpu 或 vcpu (选择的是 M-level interrupt file 还是S-level interrupt file 还是哪个 guest interrupt file )

外设硬件写msi地址后, imsci 中断控制器做出相应, 发出中断相应到 cpu csr, 根据申请中断时绑定的 interrupt file 来决定操作哪个csr:

  • 绑定了 M-level的interrupt file, 则写 mip 的 SEIP 置位
  • 绑定了 S-level 的interrupt file, 则将 sip 的 SEIP 置位
  • 绑定了 guest interrupt file[X], 则将 hgeip的对应 X bit 置位.

软件在收到中断后, 由中断管理程序查询 [m/s/vs]topi 寄存器(IMSIC 添加) , 查询最高优先级的硬件中断号, 转到对应的中断handler 进行处理.

mips gic 虚拟化

A generic External Interrupt Controller (EIC) typically has a number of input Interrupt Ports that are statically tied to devices in the system. It also has one logical output port to each core in the system, where the port has independent channels for root and guest interrupts. Input port interrupts are routed to the output ports. Logic within the EIC is implementation-dependent, although each slice of logic for the interface to the core can also have a root and guest section to configure interrupts separately for root and guest for the core. The following sections describe the interface virtualization.

一个通用的外部中断控制器(EIC)通常有一些输入的中断端口,这些端口与系统中的设备静态地联系在一起。它也有一个逻辑输出端口到系统中的每个核心,其中端口有独立的通道用于root 和guest interrupts。输入端口中断被路由到输出端口。EIC内的逻辑是依赖于实现的,尽管每个用于内核接口的逻辑片也可以有一个根和客户部分来分别配置内核的root和guest 的中断

If an EIC interrupt port is tied to either a root or guest-owned device (and not just root), the port should be modified such that it can be programmed with GuestID. In a multi-core system, each port also identifies a core destination. An interrupt can then be routed to a specific core through a specific interrupt channel (root or guest) for the core.

The logical output port to a core is split into the Root Interrupt Bus and Guess Interrupt Bus. These two independent channels route root and guest interrupts to the core. The following steps are required to deliver up to two interrupts (one for root, one for guest) in a cycle. The description assumes that interrupts are prioritized and represented by an Interrupt Priority Level.

  1. Prioritize all incoming root (GuestID=0) interrupts every cycle based on assigned Interrupt Priority Level (IPL).
    • Deliver highest priority interrupt for the cycle on Root Interrupt Bus.
  2. Prioritize all incoming guest (GuestID!=0) interrupts every cycle based on assigned Interrupt Priority Level (IPL).
    • If prioritized GuestID != resident GuestID, deliver interrupt on Root Interrupt Bus. Otherwise, deliver on Guest Interrupt Bus.
    The resident GuestID is established from an input to the EIC from each core.
    The External Interrupt Controller may reassign an interrupt from the Root Interrupt Bus to the Guest Interrupt Bus if the guest interrupt on the Root Interrupt Bus can be delivered to the core guest context as a result of a context switch, that is, the guest is now resident. Such handling is optional because root software can accomplish the same task by reprogramming the interrupt controller before switching guest context. The EIC can reassign active interrupts in this way as long as the core has not registered interrupt. This may be established by checking the Interrupt Priority Level that accompanies the interrupt acknowledgment.

中断直通场景分析

背景:
不同的vcpu可以运行不同guest os.
一个物理cpu 有三套寄存器

  • v 开头的 vsip vsie 等
  • h 开头的 hvip hip hie hgeie hgeip 等
  • s 开头的 sip sie 等

当V=0 => V=1 时, v开头的寄存器会替换成 s 开头的寄存器, 此时变成vcpu的执行环境
V=1 时, 只能访问 s 开头的寄存器.

在设备直通场景时, 0 < hstatus.VGEIN <= GEILEN(物理cpu上托管的vcpu的数量),

需要HS vmm 对 hgeie hstatus操作, 对应物理cpu上托管的vcpu
如当前物理cpu上托管了8个vcpu, 正在运行的是第2个vcpu

  • 对 hstatus.VGEIN 设置为2
  • 对 hgeie 的前8个bit 置1, 表示物理cpu 管了8个ready vcpu 的中断状态, 这8个vcpu都要处理guest external interrupt.

在前面前提下, 硬件需要将hip.VSEIP 与 hgeip的状态区分.
前面hip.VSEIP 的来源: “bit of hgeip selected by hstatus.VGEIN

在设备直通场景(设置了hstatus.VGEIN时), 中断控制器需要判断给哪个vcpu, 导致的直接结果就是要设置hgeip 的哪个bit, 同时硬件应该将 hip.VSEIP 置为hgeip 与 hstatus.VGEIN 逻辑与 的结果. 而hip.SGEIP 置为 hgeip & hgeie 逻辑与的结果.

假如中断控制器要发给第 3 个vcpu, 就需要将hgeip 的第 3 个bit 置1

  • 情景1 : 假设物理cpu的状态 V=1 mode 正在运行第二个vcpu, hstatus.VGEIN = 2:
    此时因为正在运行的是第二个vcpu, hstatus.VGEIN=2, 则hip.VSEIP = 0, 而 hip.SGEIP 为 1, 因为vsip.SEIP->sip.SEIP = hip.VSEIP, 此时vsip.SEIP 没有置位(此时假设只有外部中断, SSIP STIP 都是0), 此时硬件根据vsip penging为无信号, 而hip.SGEIP 有信号, 不能将中断委托给vcpu, 而应将中断给到 host os HS-mode的vmm.

    从vcpu陷入到hypervisor vmm 后, vmm 需要check hip.SGEIP & hie SGEIE(或hip&hie), 有待处理的虚拟外部中断, 进而查hgeip, 查到是第 3 个vcpu的, 则切换到第 3 个vcpu 运行, 切换前将hstatus.VGEIN 设置为3. 此时vsip.SEIP = hip.VSEIP 会被置1(hgeip 逻辑与 hstatus.VGEIN) , 第 3 个vcpu 陷入V=1 mode, 处理虚拟外部中断. 如guest os kernel 将sie.SEIE 置过位, 则guest os 会处理external 中断(10号guest external中断会转换成9 号external 中断), guest os 需要查询中断控制器, 判断外部中断是谁的, 该由谁的中断处理函数处理. 处理完后将中断控制器的pending 清0(该操作导致中断控制器把hgeip清0), 返回到 hypervisor vmm 后, vsip.SEIP = hip.VSEIP 也会因hgeip 而清0.

  • 情景2: 假设物理cpu的状态 V=0 mode, 处于host下
    中断由host os接收
    hypervisor vmm 需要check hip.SGEIP & hie SGEIE (或hip&hie), 有待处理的虚拟外部中断, 进而查hgeip, 查到是第三个vcpu的, 则切换到第三个vcpu 运行, 切换前将hstatus.VGEIN 设置为3. 此时vsip.SEIP = hip.VSEIP 会被置1(hgeip 逻辑与 hstatus.VGEIN) , 第三个vcpu 陷入V=1 mode, 处理虚拟外部中断. 如guest os kernel 将sie.SEIE 置过位, 则guest os 会处理external 中断(10号guest external中断会转换成9 号external 中断), guest os 需要查询中断控制器, 判断外部中断是谁的, 该由谁的中断处理函数处理. 处理完后将中断控制器的pending 清0(该操作导致中断控制器把hgeip清0), 返回到 hypervisor vmm 后, vsip.SEIP = hip.VSEIP 也会因hgeip 而清0.

  • 情景3: 假设物理cpu的状态 V=1 mode 正在运行第三个vcpu, hstatus.VGEIN = 3:
    hgeip 逻辑与 hstatus.VGEIN 不为0, hip.VSEIP 置1.
    因为vsip.SEIP->sip.SEIP = hip.VSEIP, 此时vsip.SEIP 置位, 此时硬件根据vsip penging有信号, hip.SGEIP 有信号, 应将中断给到 vcpu guest os.
    vcpu处理虚拟外部中断. 如guest os kernel 将sie.SEIE 置过位, 则guest os 会处理external 中断(10号guest external中断会转换成9 号external 中断), guest os 需要查询中断控制器, 判断外部中断是谁的, 该由谁的中断处理函数处理. 处理完后将中断控制器的pending 清0(该操作导致中断控制器把hgeip清0), 返回到 hypervisor vmm 后, vsip.SEIP = hip.VSEIP 也会因hgeip 而清0.

PCIE 设备在 riscv-iommu 支持下可以直接写 msi 地址, 发送对应的中断给对应的vcpu, riscv-iommu的该硬件设计简化了hypervisor和中断子系统的软件程序.

image-20240416112051360

arm gicv4 下的PCIE 设备直通过程

中断直通场景下的问题比较多, 比较重要的有下面几个:

  1. PCIE 设备是怎样开关中断?
  2. PCIE 设备是怎样发送中断的? 中断是怎么由os处理的?
  3. guest os 下的pcie设备是虚拟的外设, 虚拟的pcie设备怎么和真实的pcie设备建立关联的, 启用中断的操作怎么反映的PCIE的真实物理地址上的?
  4. guest os 下申请中断, guest os只有虚拟中断控制器, 它是怎样和真实的中断控制器建立连接的, 怎样设置的虚拟中断路由, 中断控制器怎么绑定的vcpu 和 对应的硬件中断号?

对于PCIE设备,一般支持MSI中断和MSIX中断,MSI和MSIX中断都是通过对某个映射内存区域写数据(写的地址为Message Addr,写的数据为Message Data),从而触发基于信息的中断。
产生MSI中断的内存映射区在PCIE设备的配置空间,而产生MSIX中断的内存映射区在PCIE设备的BAR空间
MSI中断最多支持32个,且要求申请的中断连续
而MSIX中断可支持的比较多(2048),不要求申请的中断连续;

这里仅对 MSI 类型进行分析

  1. PCIE 外设开启中断
    在 host os下的流程
  1. 写 PCIE 的 MSI对应的配置空间, 最终反映到了外设的寄存器上, 对相应的enable 位置1, 中间过程是比较复杂的, 这里暂时略过, 属于pcie driver 的范围.
    设置linux virq 和 相应外设硬件中断号的映射关系.
  2. kernel 申请virq 同硬件中断号建立映射, 最终建立硬件中断号同 irq handler 的映射关系
  1. PCIE 外设发送中断及中断处理流程
    kernel 查询中断控制器或查cpu的csr 判断哪个硬件中断号pending了, 然后根据硬件中断号找到对应的linux virq, 最终找到中断handler 进行处理

guestos 下直通的PCIE 开启中断流程

  1. PCIE 外设开启中断
    这里就比较复杂了

    首先需要说明, guest 下的中断控制器 pcie 外设等 外部控制器相关的都是qemu 模拟的
    在直通场景下, guest os下看到的外设 外部控制器这些也全是模拟的, 并不能直接访问到真实外设/控制器的物理地址

这里就涉及到两部分

  • 对中断控制器的模拟, 在直通时怎么将虚拟中断控制器的输入反馈给 真实的中断控制器
  • 对PCIE 设备的模拟, 在直通时怎么将虚拟外设的操作反馈给真实的外设

另外还有一部分是软件的流程, guest kernel下相关的 deviceid - 硬件中断号-vcpu的关系怎么路由给 host os的vmm? 为什么这么做, 大概有两点:

  • vcpu 退出了, 运行在host os下, 需要host的 cpu 接管vcpu的中断(类似的doorbell机制), 进而需要对对应的vcpu 注入中断, 再调度到vcpu
  • 无论是否直通, 外设的中断信号都是发送给真实的中断控制器的, 而guest os下都是虚拟的中断控制器, 最终建立 deviceid - 硬件中断号 -vcpu 的路由都是在host os下的vmm中完成的
  1. PCIE 设备是怎样发送中断的, 中断是怎样处理的?

PCIE 设备硬件发送中断信号给中断控制器, 中断控制器中通过中断路由表查表找到deviceid 对应的硬件中断号 cpu/vcpuid, 最后给托管vcpu的对应的物理cpu 设置pending 信号及 硬件中断号相关的寄存器等设置对应的位.

  • 物理cpu运行在host os时, 由doorbell机制处理, 对vcpu注入中断, 调度到vcpu, vcpu下的guest os 查询虚拟中断控制器或特有的vcpu相关的寄存器查询硬件中断号, 找到对应的linux virq, 最终找到irq handler 进行处理
  • 物理cpu处在运行该vcpuid的上下文时, vcpu下的guest os 查询虚拟中断控制器或特有的vcpu相关的寄存器查询硬件中断号, 找到对应的linux virq, 最终找到irq handler 进行处理
  • 物理cpu 处在另一个vcpuid的上下文时, 此时由于cpu的doorbell机制, vcpu不能收到中断, 而是由host os收到中断, 此时会因中断陷入到host os的vmm, 然后cpu 查询中断信息, 判断是哪个vcpu的中断, 然后给对应的vcpu 注入中断, 最后由该vcpu guest os 查询虚拟中断控制器或特有的vcpu相关的寄存器查询硬件中断号, 找到对应的linux virq, 最终找到irq handler 进行处理

参考arm的架构
添加的内容:

  1. kernel中kvm module下 gic 控制器ops 相关的埋桩
  2. 中断控制器的driver 对应的kvm相关的ops 实现
  3. qemu 中 虚拟中断控制器的注册流程, 最终下发给kvm, 让kvm 建立对应的映射关系.
  4. guest 虚拟中断控制器的mmio 模拟, 这部分在kvm的管理的内存下, 需要中断控制器 driver 实现 mmio的注册, 因guest page fault 陷入虚拟中断控制器对应的mmio后的读写流程, 解析guest os 下的deviceid-硬件中断号-vcpu的映射关系, 在真实的中断控制器下建立对应的vcpu的中断路由. 建立cpu-vcpu的doorbell 路由及中断注入的流程.

PCIE的部分:

  1. qemu中添加 PCIE 配置空间 BAR 空间等的mmio的模拟
  2. qemu vfio 框架中需要的所有PCIE相关的driver pcie相关的机制都要准备好.
  3. iommu 部分, 需要建立 PCIE空间(设备相关的地址空间)的 dma remap 等, 需要 iommu driver 完成.
  4. 中断部分, 需要qemu-kvm 联合将msi/msix 空间的mmio的读写进行解析, 最终转化为 对真实的中断控制器的操作, 建立真实的中断路由
    当虚拟机因为写PCI配置空间而发生VM-exit时,最终会完成msi和msix的使能,在qemu侧会设置eventfd的处理函数,并通过kvm将irqfd注册到内核中,进而注册虚拟中断给虚拟机。

image-20240416112118303

直通框架依赖的kvm的feature

riscv kvm上未实现的feature

  • ioeventfd

存在这样一种情况,即I/O请求本身只是作为一个通知事件,这个事件本身可能是通知KVM或者QEMU完成另一个具体的I/O,这种情况下没有必要像普通I/O一样等待数据完全写完,而是只需要完成一个简单的通知。
如果这种I/O请求也使用之前同步的方式完成,很明显会增加不必要的路径。
ioeventfd就是对这种通知I/O进行的优化,用户层程序(如QEMU)可以为虚拟机特定的地址关联一个eventfd,并对该eventfd进行事件监听,然后调用ioctl(KVM_IOEVENTFD)向KVM注册这段地址, 当虚拟机内部因为I/O发生VM Exit时,KVM可以判断其地址是否有对应的eventfd,如果有就直接调用eventfd_signal发送信号到对应的fd,这样,QEMU就能够从其事件监听循环返回,进而进行处理

这里与一般的mmio 的处理流程有差别的地方就是对guest 来说, 这是一个异步调用, guest 在读写某数据时, 并不会等这个数据读写完, 而是直接再次进入到guest中了. 该vcpu 只会陷入到 kvm下一次, 然后通知用户态进程, 该用户态进程并不是和这个vcpu处在同一个cpu上, 该vcpu只陷入到HS-mode的kvm一次, 就继续回到guest os中了.

  • irqfd
    ioeventfd是虚拟机内部操作系统通知KVM/QEMU的一种快捷通道, 与之类似,irqfd是KVM/QEMU通知虚拟机内部操作系统的快捷通道。irqfd将一个eventfd与一个全局的中断号联系起来,当向这个eventfd发送信号时,就会导致对应的中断注入到虚拟机中